| 1 | import { ZERO_OID, diffTrees, fromUtf8, isTreeMode, readCommit, readTree, resolvePath } from "@diffinery/git-core"; |
| 2 | import type { Actor, Conclusion, Json, JobCompletion, JobSpec, LogLine, NeedsResult, StepReport } from "@diffinery/shared"; |
| 3 | import { |
| 4 | evaluateIf, |
| 5 | evaluateValue, |
| 6 | expandMatrix, |
| 7 | interpolate, |
| 8 | isWarning, |
| 9 | jobOrder, |
| 10 | matchesTrigger, |
| 11 | parseWorkflow, |
| 12 | type ExprContext, |
| 13 | type Job, |
| 14 | type StatusFns, |
| 15 | type TriggerEvent, |
| 16 | type Workflow, |
| 17 | } from "@diffinery/workflow-yaml"; |
| 18 | import { decryptSecret, encryptSecret } from "../lib/crypto.js"; |
| 19 | import { Directory } from "../lib/directory.js"; |
| 20 | import { getFirst, headFirst, legacyArtifactKeys, legacyCacheKeys, logKeys } from "../lib/storage-keys.js"; |
| 21 | import { permissionsForJob, type JobPermissions } from "../lib/job-permissions.js"; |
| 22 | import { signRuntimeToken } from "../lib/runtime-auth.js"; |
| 23 | import { ghRepo, numericId } from "../http/gh.js"; |
| 24 | import { HttpError, newId, now, randomToken, sha256 } from "../lib/util.js"; |
| 25 | import { endCompute, mayStart, startCompute } from "../lib/quotas.js"; |
| 26 | import type { Env } from "../env.js"; |
| 27 | import type { RepoDO } from "./RepoDO.js"; |
| 28 | import { completePayload } from "./event-payloads.js"; |
| 29 | import { CACHE_IDLE_MS, CACHE_LIMIT_BYTES, artifactFileName, artifactObjectKeys } from "./runtime.js"; |
| 30 | import { atLeast, type Caller } from "./types.js"; |
| 31 | import { isJobCaller } from "./token-policy.js"; |
| 32 | import { ReusableError, expandReusable, localKey, parseCallRef, type Expanded, type Source } from "./reusable.js"; |
| 33 | import { ancestors, combineResults, jobStatus, runnerLabels, type JobNode } from "./job-graph.js"; |
| 34 | import { schedulerStub } from "../lib/schedules.js"; |
| 35 | import { APPROVAL_DAYS, type Deployment, type DeploymentReview, type Reviewer } from "./deployments.js"; |
| 36 | import { referencedSecrets, RUNNER_SECRETS, secretsFor } from "./job-secrets.js"; |
| 37 | |
| 38 | /** How long a sandbox token works after the sandbox starts. */ |
| 39 | export const SANDBOX_TOKEN_TTL_MS = 24 * 3600_000; |
| 40 | /** Sandboxes one person or agent may have open in a repository at once: their containers are shared by the whole instance. */ |
| 41 | export const MAX_OPEN_SANDBOXES = 5; |
| 42 | |
| 43 | const WORKFLOW_DIRS = [".github/workflows", ".diffinery/workflows"]; |
| 44 | /** Characters of text a job's log keeps; the rest is dropped after a line saying so. */ |
| 45 | export const MAX_JOB_LOG = 16 * 1048576; |
| 46 | /** Every push reads and parses a commit's workflow files: at most this many, each at most this large. */ |
| 47 | export const MAX_WORKFLOW_FILES = 256; |
| 48 | export const MAX_WORKFLOW_BYTES = 512 * 1024; |
| 49 | /** Hosted jobs one repository runs at once (GitHub's free plan allows 20 per account); more wait their turn. */ |
| 50 | export const MAX_HOSTED_JOBS = 20; |
| 51 | /** |
| 52 | * A hosted job whose container never got a machine (the platform had none free, usually when many |
| 53 | * jobs start at once or right after a deploy) goes back in line and starts again later, this many |
| 54 | * times, before it fails. Only jobs whose runner never reported in: nothing of theirs has run. |
| 55 | */ |
| 56 | export const MAX_PLACEMENT_TRIES = 8; |
| 57 | /** Seconds before the next start of a job whose container got no machine: 30 s, doubling to 5 minutes. */ |
| 58 | export const placementDelay = (tries: number): number => Math.min(30 * 2 ** (tries - 1), 300); |
| 59 | const NO_INSTANCE = "no container instance"; |
| 60 | /** Runs one repository may have unfinished at once; past it, new ones aren't started. */ |
| 61 | export const MAX_ACTIVE_RUNS = 500; |
| 62 | /** Self-hosted runners one repository may have registered. */ |
| 63 | export const MAX_RUNNERS = 100; |
| 64 | /** How long a runner registration token works, as on GitHub. */ |
| 65 | const REGISTRATION_TTL_MS = 3600_000; |
| 66 | /** A runner that polled this recently shows as online. */ |
| 67 | const RUNNER_ONLINE_MS = 90_000; |
| 68 | |
| 69 | type RunnerRow = { id: number; name: string; labels: string; token_hash: string | null; ephemeral: number; created_by: string; created_at: string; last_seen_at: string | null }; |
| 70 | |
| 71 | export interface RunnerInfo { |
| 72 | id: number; |
| 73 | name: string; |
| 74 | os: string; |
| 75 | status: "online" | "offline"; |
| 76 | busy: boolean; |
| 77 | ephemeral: boolean; |
| 78 | labels: Array<{ id: number; name: string; type: "read-only" | "custom" }>; |
| 79 | } |
| 80 | const SUPPORTED_LABEL = /^(ubuntu-.*|linux|diffinery(-.*)?|self-hosted|x64|amd64)$/i; |
| 81 | |
| 82 | export type RunRow = { |
| 83 | id: string; |
| 84 | run_number: number; |
| 85 | workflow_path: string; |
| 86 | workflow_name: string; |
| 87 | event: string; |
| 88 | ref: string; |
| 89 | sha: string; |
| 90 | actor: string; |
| 91 | status: "queued" | "in_progress" | "completed" | "waiting"; |
| 92 | conclusion: Conclusion | "timed_out" | null; |
| 93 | created_at: string; |
| 94 | updated_at: string; |
| 95 | pr_number: number | null; |
| 96 | attempt: number; |
| 97 | event_payload: string; |
| 98 | inputs: string; |
| 99 | concurrency_group: string | null; |
| 100 | display_title: string; |
| 101 | /** Commit the run reports checks on, when it differs from `sha` (pull request runs). */ |
| 102 | head_sha: string | null; |
| 103 | /** 1 when the code the run executes came from an agent; its jobs then act as an agent. */ |
| 104 | agent_origin?: number; |
| 105 | /** Account ids that started earlier attempts of this run (JSON list), for prevent_self_review. */ |
| 106 | prior_actors?: string | null; |
| 107 | }; |
| 108 | |
| 109 | export type JobRow = { |
| 110 | id: string; |
| 111 | run_id: string; |
| 112 | job_key: string; |
| 113 | name: string; |
| 114 | /** |
| 115 | * pending: held back by its concurrency group until the group's running job finishes. |
| 116 | * approval: waiting on its environment's required reviewers or wait timer; nothing has started. |
| 117 | */ |
| 118 | status: "queued" | "pending" | "approval" | "waiting" | "in_progress" | "completed"; |
| 119 | conclusion: Conclusion | "timed_out" | null; |
| 120 | matrix: string; |
| 121 | strategy: string; |
| 122 | started_at: string | null; |
| 123 | completed_at: string | null; |
| 124 | outputs: string; |
| 125 | steps: string; |
| 126 | failure: string | null; |
| 127 | token_hash: string | null; |
| 128 | summary: string | null; |
| 129 | container: string | null; |
| 130 | timeout_minutes: number; |
| 131 | /** runs-on labels for self-hosted jobs (JSON array); null for Diffinery-hosted containers. */ |
| 132 | labels: string | null; |
| 133 | /** `continue-on-error` as evaluated for this matrix leg (0 or 1). */ |
| 134 | continue_on_error: number; |
| 135 | /** Job-level `concurrency` group as evaluated for this leg, and its cancel-in-progress (0 or 1). */ |
| 136 | concurrency_group: string | null; |
| 137 | concurrency_cancel: number; |
| 138 | log_chars: number; |
| 139 | fleet_held: number; |
| 140 | runner_id: number | null; |
| 141 | /** `environment:` as evaluated at launch (null for jobs without one, and for jobs launched before it was stored). */ |
| 142 | environment: string | null; |
| 143 | /** When the job started waiting on its environment's protection rules, and "released" once they passed. */ |
| 144 | gate_since: string | null; |
| 145 | gate_state: string | null; |
| 146 | placement_tries: number; |
| 147 | }; |
| 148 | |
| 149 | /** An environment a run's jobs wait on (GitHub's pending deployment). */ |
| 150 | export type PendingDeployment = { |
| 151 | environment: { id: number; name: string }; |
| 152 | waitTimer: number; |
| 153 | waitTimerStartedAt: string; |
| 154 | reviewers: Reviewer[]; |
| 155 | preventSelfReview: boolean; |
| 156 | /** Still waiting for a required reviewer (false once approved, or when only a timer applies). */ |
| 157 | needsReview: boolean; |
| 158 | currentUserCanApprove: boolean; |
| 159 | /** The viewer is listed but started the run, and the environment prevents self-review. */ |
| 160 | selfBlocked: boolean; |
| 161 | }; |
| 162 | |
| 163 | export type RunInfo = { |
| 164 | id: string; |
| 165 | number: number; |
| 166 | attempt: number; |
| 167 | workflowPath: string; |
| 168 | workflowName: string; |
| 169 | title: string; |
| 170 | event: string; |
| 171 | ref: string; |
| 172 | sha: string; |
| 173 | /** The commit checks are reported on: the pull request head for pull_request runs. */ |
| 174 | headSha: string; |
| 175 | /** Branch the run is about: the pull request's head branch for pull request runs. */ |
| 176 | headBranch: string; |
| 177 | actor: Actor; |
| 178 | status: RunRow["status"]; |
| 179 | conclusion: RunRow["conclusion"]; |
| 180 | createdAt: string; |
| 181 | updatedAt: string; |
| 182 | prNumber: number | null; |
| 183 | }; |
| 184 | |
| 185 | export type JobInfo = { |
| 186 | id: string; |
| 187 | runId: string; |
| 188 | key: string; |
| 189 | name: string; |
| 190 | status: JobRow["status"]; |
| 191 | conclusion: JobRow["conclusion"]; |
| 192 | matrix: Record<string, Json>; |
| 193 | startedAt: string | null; |
| 194 | completedAt: string | null; |
| 195 | outputs: Record<string, string>; |
| 196 | steps: Array<StepReport & { index: number }>; |
| 197 | failure: JobCompletion["failure"] | null; |
| 198 | summary: string | null; |
| 199 | }; |
| 200 | |
| 201 | const toRun = (r: RunRow): RunInfo => ({ |
| 202 | id: r.id, |
| 203 | number: r.run_number, |
| 204 | attempt: r.attempt, |
| 205 | workflowPath: r.workflow_path, |
| 206 | workflowName: r.workflow_name, |
| 207 | title: r.display_title, |
| 208 | event: r.event, |
| 209 | ref: r.ref, |
| 210 | sha: r.sha, |
| 211 | headSha: r.head_sha ?? r.sha, |
| 212 | headBranch: (r.pr_number ? (JSON.parse(r.event_payload) as { pull_request?: { head?: { ref?: string } } }).pull_request?.head?.ref : undefined) ?? r.ref.replace(/^refs\/(heads|tags)\//, ""), |
| 213 | actor: JSON.parse(r.actor), |
| 214 | status: r.status, |
| 215 | conclusion: r.conclusion, |
| 216 | createdAt: r.created_at, |
| 217 | updatedAt: r.updated_at, |
| 218 | prNumber: r.pr_number, |
| 219 | }); |
| 220 | |
| 221 | const toJob = (j: JobRow): JobInfo => ({ |
| 222 | id: j.id, |
| 223 | runId: j.run_id, |
| 224 | key: j.job_key, |
| 225 | name: j.name, |
| 226 | status: j.status, |
| 227 | conclusion: j.conclusion, |
| 228 | matrix: JSON.parse(j.matrix), |
| 229 | startedAt: j.started_at, |
| 230 | completedAt: j.completed_at, |
| 231 | outputs: JSON.parse(j.outputs), |
| 232 | steps: JSON.parse(j.steps), |
| 233 | failure: j.failure ? JSON.parse(j.failure) : null, |
| 234 | summary: j.summary, |
| 235 | }); |
| 236 | |
| 237 | /** |
| 238 | * GitHub Actions compatible run orchestration state. The Cloudflare Workflow |
| 239 | * (`RunWorkflow`) drives a run through these methods; the runner agent inside |
| 240 | * each job's container reports back through the runner* methods. |
| 241 | */ |
| 242 | export class Actions { |
| 243 | constructor( |
| 244 | private repo: RepoDO, |
| 245 | private env: Env, |
| 246 | ) {} |
| 247 | |
| 248 | private get sql() { |
| 249 | return this.repo.sql; |
| 250 | } |
| 251 | |
| 252 | // ---------------------------------------------------------------- workflow discovery |
| 253 | |
| 254 | async loadWorkflows(sha: string): Promise<Array<{ path: string; workflow?: Workflow; errors: string[] }>> { |
| 255 | const commit = await readCommit(this.repo.store, sha); |
| 256 | const out: Array<{ path: string; workflow?: Workflow; errors: string[] }> = []; |
| 257 | for (const dir of WORKFLOW_DIRS) { |
| 258 | const entry = await resolvePath(this.repo.store, commit.tree, dir); |
| 259 | if (!entry || !isTreeMode(entry.mode)) continue; |
| 260 | for (const f of await readTree(this.repo.store, entry.oid)) { |
| 261 | if (!/\.ya?ml$/.test(f.name) || isTreeMode(f.mode)) continue; |
| 262 | if (out.length >= MAX_WORKFLOW_FILES) break; |
| 263 | const path = `${dir}/${f.name}`; |
| 264 | if ((this.repo.store.size(f.oid) ?? 0) > MAX_WORKFLOW_BYTES) { |
| 265 | out.push({ path, errors: [`: the workflow file is larger than ${MAX_WORKFLOW_BYTES / 1024} KiB`] }); |
| 266 | continue; |
| 267 | } |
| 268 | const o = await this.repo.store.read(f.oid); |
| 269 | if (!o) continue; |
| 270 | const { workflow, errors } = parseWorkflow(fromUtf8(o.content), path); |
| 271 | out.push({ path, workflow, errors: errors.filter((e) => !isWarning(e)).map((e) => `${e.path}${e.line ? `:${e.line}` : ""}: ${e.message}`) }); |
| 272 | } |
| 273 | } |
| 274 | return out; |
| 275 | } |
| 276 | |
| 277 | async listWorkflows(): Promise<Array<{ path: string; name: string; errors: string[]; triggers: string[] }>> { |
| 278 | const sha = await this.repo.resolve("HEAD"); |
| 279 | if (!sha) return []; |
| 280 | return (await this.loadWorkflows(sha)).map((w) => ({ path: w.path, name: w.workflow?.name ?? w.path.split("/").pop()!, errors: w.errors, triggers: w.workflow ? Object.keys(w.workflow.on) : [] })); |
| 281 | } |
| 282 | |
| 283 | /** The `repository` object of event payloads: GitHub's shape, with the repository's real visibility. */ |
| 284 | async repositoryPayload(): Promise<Record<string, unknown>> { |
| 285 | const m = this.repo.meta; |
| 286 | const row = await new Directory(this.env).repoById(m.repoId); |
| 287 | if (row) return ghRepo(this.env.PUBLIC_URL, row); |
| 288 | return { id: numericId(m.repoId), name: m.name, full_name: `${m.owner}/${m.name}`, owner: { login: m.owner }, default_branch: m.defaultBranch, private: true, visibility: "private", html_url: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}`, clone_url: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}.git` }; |
| 289 | } |
| 290 | |
| 291 | // ---------------------------------------------------------------- triggers |
| 292 | |
| 293 | async onPush(ev: { ref: string; before: string; after: string; forced: boolean; created: boolean; deleted: boolean }, caller: Caller): Promise<void> { |
| 294 | if (ev.deleted) return; |
| 295 | const workflows = await this.loadWorkflows(ev.after); |
| 296 | let changedFiles: string[] | undefined; |
| 297 | if (ev.before !== ZERO_OID && this.repo.store.has(ev.before)) { |
| 298 | const [a, b] = await Promise.all([readCommit(this.repo.store, ev.before), readCommit(this.repo.store, ev.after)]); |
| 299 | changedFiles = (await diffTrees(this.repo.store, a.tree, b.tree)).map((c) => c.path); |
| 300 | } |
| 301 | const head = await readCommit(this.repo.store, ev.after); |
| 302 | const payload = { |
| 303 | ref: ev.ref, |
| 304 | before: ev.before, |
| 305 | after: ev.after, |
| 306 | created: ev.created, |
| 307 | deleted: ev.deleted, |
| 308 | forced: ev.forced, |
| 309 | repository: await this.repositoryPayload(), |
| 310 | pusher: { name: caller.login }, |
| 311 | sender: { login: caller.login, type: caller.kind === "agent" ? "Bot" : "User" }, |
| 312 | head_commit: { id: ev.after, message: head.message, timestamp: new Date(head.committer.timestamp * 1000).toISOString(), author: { name: head.author.name, email: head.author.email } }, |
| 313 | commits: [{ id: ev.after, message: head.message }], |
| 314 | }; |
| 315 | if (ev.ref === `refs/heads/${this.repo.meta.defaultBranch}`) await this.syncSchedules(workflows); |
| 316 | for (const w of workflows) { |
| 317 | if (!w.workflow) { |
| 318 | if (w.errors.length) await this.createFailedRun(w.path, "push", ev.ref, ev.after, caller, w.errors); |
| 319 | continue; |
| 320 | } |
| 321 | if (!matchesTrigger(w.workflow, { name: "push", ref: ev.ref, changedFiles })) continue; |
| 322 | await this.createRun(w.workflow, { event: "push", ref: ev.ref, sha: ev.after, payload, caller, title: head.message.split("\n")[0] ?? "" }); |
| 323 | } |
| 324 | } |
| 325 | |
| 326 | /** |
| 327 | * Pull request workflows, like GitHub's: `pull_request` runs the workflow from the test |
| 328 | * merge commit (`refs/pull/<n>/merge`, the head merged into the base; nothing runs while |
| 329 | * they conflict), and `pull_request_target` runs the base branch's own workflow on the |
| 330 | * base commit, so a pull request can never change what runs with the base's secrets. |
| 331 | * Both report their checks on the pull request's head commit. |
| 332 | */ |
| 333 | async onPullRequest(action: string, number: number, caller: Caller): Promise<void> { |
| 334 | const { issue } = this.repo.collab.get(number); |
| 335 | const pull = issue.pull!; |
| 336 | // Once merged, the base already holds the changes: keep the refs as they were and let path filters pass. |
| 337 | const { baseSha, mergeSha } = pull.merged ? { baseSha: this.repo.getRef(`refs/heads/${pull.base}`), mergeSha: null } : await this.repo.collab.refreshPullRefs(number); |
| 338 | const changedFiles = pull.merged ? undefined : (await this.repo.collab.pullFiles(number)).files.map((f) => f.path); |
| 339 | const m = this.repo.meta; |
| 340 | const repository = await this.repositoryPayload(); |
| 341 | const payload = { |
| 342 | action, |
| 343 | number, |
| 344 | pull_request: { |
| 345 | number, |
| 346 | id: number, |
| 347 | state: issue.state, |
| 348 | title: issue.title, |
| 349 | body: issue.body, |
| 350 | draft: pull.draft, |
| 351 | merged: pull.merged, |
| 352 | merged_at: pull.mergedAt, |
| 353 | merged_by: pull.mergedBy ? { login: pull.mergedBy } : null, |
| 354 | merge_commit_sha: pull.merged ? pull.mergeCommit : mergeSha, |
| 355 | user: { login: issue.author.login }, |
| 356 | labels: issue.labels.map((name) => ({ name })), |
| 357 | assignees: issue.assignees.map((login) => ({ login })), |
| 358 | head: { ref: pull.head, sha: pull.headSha, repo: repository }, |
| 359 | base: { ref: pull.base, sha: baseSha, repo: repository }, |
| 360 | html_url: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}/pull/${number}`, |
| 361 | url: `${this.env.PUBLIC_URL}/api/v3/repos/${m.owner}/${m.name}/pulls/${number}`, |
| 362 | }, |
| 363 | repository, |
| 364 | sender: { login: caller.login }, |
| 365 | }; |
| 366 | const common = { payload, caller, prNumber: number, headSha: pull.headSha, headRef: pull.head, baseRef: pull.base, title: issue.title }; |
| 367 | // A merged pull request's runs use its merge commit, as on GitHub. |
| 368 | const prSha = pull.merged ? pull.mergeCommit : mergeSha; |
| 369 | if (prSha) { |
| 370 | for (const w of await this.loadWorkflows(prSha)) { |
| 371 | if (!w.workflow || !matchesTrigger(w.workflow, { name: "pull_request", action, baseRef: pull.base, changedFiles })) continue; |
| 372 | await this.createRun(w.workflow, { ...common, event: "pull_request", ref: `refs/pull/${number}/merge`, sha: prSha }); |
| 373 | } |
| 374 | } |
| 375 | if (baseSha) { |
| 376 | for (const w of await this.loadWorkflows(baseSha)) { |
| 377 | if (!w.workflow || !matchesTrigger(w.workflow, { name: "pull_request_target", action, baseRef: pull.base, changedFiles })) continue; |
| 378 | await this.createRun(w.workflow, { ...common, event: "pull_request_target", ref: `refs/heads/${pull.base}`, sha: baseSha }); |
| 379 | } |
| 380 | } |
| 381 | } |
| 382 | |
| 383 | /** `merge_group` workflows for a merge queue group, run from the group's commit. */ |
| 384 | async onMergeGroup(g: { number: number; base: string; ref: string; sha: string; parent: string; title: string; sender: Actor }): Promise<void> { |
| 385 | const head = await readCommit(this.repo.store, g.sha); |
| 386 | const payload = { |
| 387 | action: "checks_requested", |
| 388 | merge_group: { |
| 389 | head_sha: g.sha, |
| 390 | head_ref: g.ref, |
| 391 | base_sha: g.parent, |
| 392 | base_ref: `refs/heads/${g.base}`, |
| 393 | head_commit: { |
| 394 | id: g.sha, |
| 395 | tree_id: head.tree, |
| 396 | message: head.message, |
| 397 | timestamp: new Date(head.committer.timestamp * 1000).toISOString(), |
| 398 | author: { name: head.author.name, email: head.author.email }, |
| 399 | committer: { name: head.committer.name, email: head.committer.email }, |
| 400 | }, |
| 401 | }, |
| 402 | repository: await this.repositoryPayload(), |
| 403 | sender: { login: g.sender.login }, |
| 404 | }; |
| 405 | for (const w of await this.loadWorkflows(g.sha)) { |
| 406 | if (!w.workflow) { |
| 407 | if (w.errors.length) await this.createFailedRun(w.path, "merge_group", g.ref, g.sha, g.sender, w.errors); |
| 408 | continue; |
| 409 | } |
| 410 | if (!matchesTrigger(w.workflow, { name: "merge_group", action: "checks_requested", baseRef: g.base })) continue; |
| 411 | await this.createRun(w.workflow, { event: "merge_group", ref: g.ref, sha: g.sha, payload, caller: g.sender, title: `Merge group for #${g.number}: ${g.title}`, prNumbers: [g.number] }); |
| 412 | } |
| 413 | } |
| 414 | |
| 415 | /** Cancel unfinished runs on a ref (a merge group branch that is going away). */ |
| 416 | async cancelRunsOnRef(ref: string, reason: string): Promise<void> { |
| 417 | for (const r of this.sql.exec<{ id: string }>("SELECT id FROM runs WHERE ref = ? AND status != 'completed'", ref).toArray()) await this.cancelInternal(r.id, reason); |
| 418 | } |
| 419 | |
| 420 | async dispatch(workflowFile: string, ref: string, inputs: Record<string, unknown>, caller: Caller): Promise<RunInfo> { |
| 421 | if (!atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to run workflows"); |
| 422 | const sha = await this.repo.resolve(ref); |
| 423 | if (!sha) throw new HttpError(422, `unknown ref ${ref}`); |
| 424 | const all = await this.loadWorkflows(sha); |
| 425 | const w = all.find((x) => x.path === workflowFile || x.path.endsWith(`/${workflowFile}`)); |
| 426 | if (!w?.workflow) throw new HttpError(404, `workflow ${workflowFile} not found at ${ref}${w?.errors.length ? `: ${w.errors.join("; ")}` : ""}`); |
| 427 | if (!w.workflow.on.workflow_dispatch) throw new HttpError(422, "workflow does not have a workflow_dispatch trigger"); |
| 428 | const defs = w.workflow.on.workflow_dispatch.inputs ?? {}; |
| 429 | const resolved: Record<string, unknown> = {}; |
| 430 | for (const [k, d] of Object.entries(defs)) { |
| 431 | const v = inputs[k] ?? d.default; |
| 432 | if (d.required && (v === undefined || v === "")) throw new HttpError(422, `input '${k}' is required`); |
| 433 | if (d.type === "choice" && d.options && v !== undefined && !d.options.includes(String(v))) throw new HttpError(422, `input '${k}' must be one of ${d.options.join(", ")}`); |
| 434 | resolved[k] = d.type === "boolean" ? v === true || v === "true" : d.type === "number" ? Number(v) : v; |
| 435 | } |
| 436 | const fullRef = ref.startsWith("refs/") ? ref : this.repo.getRef(`refs/heads/${ref}`) ? `refs/heads/${ref}` : `refs/tags/${ref}`; |
| 437 | const payload = { inputs: resolved, ref: fullRef, repository: await this.repositoryPayload(), sender: { login: caller.login }, workflow: w.path }; |
| 438 | const m = this.repo.meta; |
| 439 | this.repo.emit({ type: "workflow_dispatch", repoId: m.repoId, owner: m.owner, repo: m.name, ref: fullRef, workflow: w.path, inputs: resolved as Record<string, Json>, actor: { id: caller.id, login: caller.login, kind: caller.kind, onBehalfOf: caller.onBehalfOf }, at: now() }); |
| 440 | return (await this.createRun(w.workflow, { event: "workflow_dispatch", ref: fullRef, sha, payload, caller, inputs: resolved, title: w.workflow.name ?? w.path }))!; |
| 441 | } |
| 442 | |
| 443 | async schedule(workflowPath: string, cron: string): Promise<void> { |
| 444 | const sha = await this.repo.resolve("HEAD"); |
| 445 | if (!sha) return; |
| 446 | const w = (await this.loadWorkflows(sha)).find((x) => x.path === workflowPath); |
| 447 | if (!w?.workflow || !matchesTrigger(w.workflow, { name: "schedule", cron })) return; |
| 448 | const system: Caller = { id: "system", login: "diffinery-scheduler", kind: "system", role: "write" }; |
| 449 | await this.createRun(w.workflow, { event: "schedule", ref: this.repo.headRef, sha, payload: { schedule: cron, repository: await this.repositoryPayload() }, caller: system, title: w.workflow.name ?? w.path }); |
| 450 | } |
| 451 | |
| 452 | private async syncSchedules(workflows: Array<{ path: string; workflow?: Workflow }>): Promise<void> { |
| 453 | const repoId = this.repo.meta.repoId; |
| 454 | const stmts = [this.env.DB.prepare("DELETE FROM schedules WHERE repo_id = ?").bind(repoId)]; |
| 455 | for (const w of workflows) for (const s of w.workflow?.on.schedule ?? []) stmts.push(this.env.DB.prepare("INSERT OR IGNORE INTO schedules (repo_id, workflow_path, cron) VALUES (?, ?, ?)").bind(repoId, w.path, s.cron)); |
| 456 | await this.env.DB.batch(stmts); |
| 457 | // The scheduler's alarm starts with the first schedule, without waiting for the cron trigger. |
| 458 | if (stmts.length > 1) await schedulerStub(this.env).ensure().catch((e) => console.error("scheduler alarm check failed", e)); |
| 459 | } |
| 460 | |
| 461 | // ---------------------------------------------------------------- runs |
| 462 | |
| 463 | private nextRunNumber(): number { |
| 464 | this.sql.exec("INSERT INTO counters (name, value) VALUES ('run', 1) ON CONFLICT(name) DO UPDATE SET value = value + 1"); |
| 465 | return this.sql.exec<{ value: number }>("SELECT value FROM counters WHERE name = 'run'").one().value; |
| 466 | } |
| 467 | |
| 468 | private githubContext(run: RunRow, jobKey: string, token: string): Record<string, unknown> { |
| 469 | const m = this.repo.meta; |
| 470 | const payload = JSON.parse(run.event_payload) as Record<string, unknown>; |
| 471 | const actor = JSON.parse(run.actor) as Actor; |
| 472 | const refName = run.ref.replace(/^refs\/(heads|tags)\//, "").replace(/^refs\/pull\/(\d+)\/(head|merge)$/, "$1/merge"); |
| 473 | const pr = payload.pull_request as { head?: { ref?: string }; base?: { ref?: string } } | undefined; |
| 474 | return { |
| 475 | event_name: run.event, |
| 476 | event: payload, |
| 477 | sha: run.sha, |
| 478 | ref: run.ref, |
| 479 | ref_name: refName, |
| 480 | ref_type: run.ref.startsWith("refs/tags/") ? "tag" : "branch", |
| 481 | ref_protected: false, |
| 482 | head_ref: pr?.head?.ref ?? "", |
| 483 | base_ref: pr?.base?.ref ?? "", |
| 484 | repository: `${m.owner}/${m.name}`, |
| 485 | repository_id: String(numericId(m.repoId)), |
| 486 | repository_owner: m.owner, |
| 487 | repositoryUrl: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}.git`, |
| 488 | actor: actor.login, |
| 489 | triggering_actor: actor.login, |
| 490 | // Numeric like GitHub's (actions parse it, e.g. download-artifact's run-id); the API accepts it as a run id. |
| 491 | run_id: String(run.run_number), |
| 492 | run_number: String(run.run_number), |
| 493 | run_attempt: String(run.attempt), |
| 494 | workflow: run.workflow_name, |
| 495 | workflow_ref: `${m.owner}/${m.name}/${run.workflow_path}@${run.ref}`, |
| 496 | job: jobKey, |
| 497 | server_url: this.env.PUBLIC_URL, |
| 498 | api_url: `${this.env.PUBLIC_URL}/api/v3`, |
| 499 | graphql_url: `${this.env.PUBLIC_URL}/api/graphql`, |
| 500 | token, |
| 501 | retention_days: "90", |
| 502 | secret_source: "Actions", |
| 503 | }; |
| 504 | } |
| 505 | |
| 506 | private async createFailedRun(path: string, event: string, ref: string, sha: string, caller: Actor, errors: string[]): Promise<void> { |
| 507 | const id = newId(); |
| 508 | const t = now(); |
| 509 | this.sql.exec( |
| 510 | "INSERT INTO runs (id, run_number, workflow_path, workflow_name, event, ref, sha, actor, status, conclusion, created_at, updated_at, event_payload, display_title) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'completed', 'failure', ?, ?, ?, ?)", |
| 511 | id, |
| 512 | this.nextRunNumber(), |
| 513 | path, |
| 514 | path, |
| 515 | event, |
| 516 | ref, |
| 517 | sha, |
| 518 | JSON.stringify({ id: caller.id, login: caller.login, kind: caller.kind }), |
| 519 | t, |
| 520 | t, |
| 521 | "{}", |
| 522 | `Invalid workflow file: ${errors[0] ?? path}`, |
| 523 | ); |
| 524 | const jobId = newId(); |
| 525 | this.sql.exec( |
| 526 | "INSERT INTO jobs (id, run_id, job_key, name, status, conclusion, started_at, completed_at, failure) VALUES (?, ?, 'invalid', 'Invalid workflow file', 'completed', 'failure', ?, ?, ?)", |
| 527 | jobId, |
| 528 | id, |
| 529 | t, |
| 530 | t, |
| 531 | JSON.stringify({ message: errors.join("\n") }), |
| 532 | ); |
| 533 | } |
| 534 | |
| 535 | async createRun( |
| 536 | wf: Workflow, |
| 537 | o: { |
| 538 | event: string; |
| 539 | ref: string; |
| 540 | sha: string; |
| 541 | headSha?: string; |
| 542 | payload: Record<string, unknown>; |
| 543 | caller: Caller | { id: string; login: string; kind: Actor["kind"] }; |
| 544 | prNumber?: number; |
| 545 | inputs?: Record<string, unknown>; |
| 546 | headRef?: string; |
| 547 | baseRef?: string; |
| 548 | title: string; |
| 549 | /** Started by a run whose code came from an agent (a re-run, or a workflow_run follower). */ |
| 550 | agentOrigin?: boolean; |
| 551 | /** Pull requests whose code the run includes (a merge group's). */ |
| 552 | prNumbers?: number[]; |
| 553 | }, |
| 554 | ): Promise<RunInfo | null> { |
| 555 | // Like GitHub, what a job does with its GITHUB_TOKEN (pushes, pull requests) starts no new |
| 556 | // runs, so workflows cannot trigger themselves in a loop; workflow_dispatch and |
| 557 | // repository_dispatch are the exceptions. |
| 558 | if (isJobCaller(o.caller) && o.event !== "workflow_dispatch" && o.event !== "repository_dispatch") return null; |
| 559 | // A dispatch loop (or a flood of pushes) stops growing here. |
| 560 | const active = this.sql.exec<{ n: number }>("SELECT count(*) AS n FROM runs WHERE status != 'completed'").one().n; |
| 561 | if (active >= MAX_ACTIVE_RUNS) { |
| 562 | if (o.event === "workflow_dispatch" || o.event === "repository_dispatch") throw new HttpError(429, `this repository has ${active} unfinished workflow runs, the most allowed; wait for some to finish`); |
| 563 | console.warn(`not starting ${wf.path}: ${active} unfinished runs`); |
| 564 | return null; |
| 565 | } |
| 566 | const id = newId(); |
| 567 | const t = now(); |
| 568 | const run: RunRow = { |
| 569 | id, |
| 570 | run_number: this.nextRunNumber(), |
| 571 | workflow_path: wf.path, |
| 572 | workflow_name: wf.name ?? wf.path, |
| 573 | event: o.event, |
| 574 | ref: o.ref, |
| 575 | sha: o.sha, |
| 576 | actor: JSON.stringify({ id: o.caller.id, login: o.caller.login, kind: o.caller.kind }), |
| 577 | status: "queued", |
| 578 | conclusion: null, |
| 579 | created_at: t, |
| 580 | updated_at: t, |
| 581 | pr_number: o.prNumber ?? null, |
| 582 | attempt: 1, |
| 583 | event_payload: JSON.stringify(completePayload({ publicUrl: this.env.PUBLIC_URL, meta: this.repo.meta, visibility: (o.payload.repository as { visibility?: string } | undefined)?.visibility === "public" ? "public" : "private" }, o.payload, o.caller)), |
| 584 | inputs: JSON.stringify(o.inputs ?? {}), |
| 585 | concurrency_group: null, |
| 586 | display_title: o.title, |
| 587 | head_sha: o.headSha && o.headSha !== o.sha ? o.headSha : null, |
| 588 | agent_origin: this.repo.policy.agentOrigin({ |
| 589 | caller: o.caller, |
| 590 | ref: o.ref, |
| 591 | prNumbers: [...(o.prNumber !== undefined ? [o.prNumber] : []), ...(o.prNumbers ?? [])], |
| 592 | inherited: o.agentOrigin, |
| 593 | }) |
| 594 | ? 1 |
| 595 | : 0, |
| 596 | }; |
| 597 | const ctx: ExprContext = { github: this.githubContext(run, "", ""), inputs: o.inputs ?? {}, vars: this.variables() }; |
| 598 | try { |
| 599 | if (wf.runName) run.display_title = interpolate(wf.runName, ctx); |
| 600 | if (wf.concurrency) run.concurrency_group = interpolate(wf.concurrency.group, ctx); |
| 601 | } catch (e) { |
| 602 | console.warn("run-name/concurrency evaluation failed", e); |
| 603 | } |
| 604 | this.sql.exec( |
| 605 | "INSERT INTO runs (id, run_number, workflow_path, workflow_name, event, ref, sha, actor, status, created_at, updated_at, pr_number, attempt, event_payload, inputs, concurrency_group, display_title, head_sha, agent_origin) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", |
| 606 | run.id, |
| 607 | run.run_number, |
| 608 | run.workflow_path, |
| 609 | run.workflow_name, |
| 610 | run.event, |
| 611 | run.ref, |
| 612 | run.sha, |
| 613 | run.actor, |
| 614 | run.status, |
| 615 | run.created_at, |
| 616 | run.updated_at, |
| 617 | run.pr_number, |
| 618 | run.attempt, |
| 619 | run.event_payload, |
| 620 | run.inputs, |
| 621 | run.concurrency_group, |
| 622 | run.display_title, |
| 623 | run.head_sha, |
| 624 | run.agent_origin ?? 0, |
| 625 | ); |
| 626 | if (run.concurrency_group && wf.concurrency) { |
| 627 | const cancelInProgress = evaluateValue(wf.concurrency.cancelInProgress, ctx); |
| 628 | const others = this.sql.exec<{ id: string }>("SELECT id FROM runs WHERE concurrency_group = ? AND status != 'completed' AND id != ?", run.concurrency_group, id).toArray(); |
| 629 | for (const other of others) if (cancelInProgress === true || cancelInProgress === "true") await this.cancelInternal(other.id, "cancelled by a newer run in the same concurrency group"); |
| 630 | } |
| 631 | await this.env.RUN_WORKFLOW.create({ id, params: { repoId: this.repo.meta.repoId, runId: id } }); |
| 632 | const m = this.repo.meta; |
| 633 | this.repo.emit({ type: "workflow_run", action: "requested", repoId: m.repoId, owner: m.owner, repo: m.name, runId: id, actor: JSON.parse(run.actor), at: t }); |
| 634 | this.repo.broadcast("runs", { action: "created", runId: id }); |
| 635 | await this.onWorkflowRun(run, "requested"); |
| 636 | return toRun(run); |
| 637 | } |
| 638 | |
| 639 | /** A run by id, or by its number (github.run_id; the latest attempt). */ |
| 640 | private runRow(id: string): RunRow { |
| 641 | const r = /^\d+$/.test(id) |
| 642 | ? this.sql.exec<RunRow>("SELECT * FROM runs WHERE run_number = ? ORDER BY attempt DESC, created_at DESC LIMIT 1", Number(id)).toArray()[0] |
| 643 | : this.sql.exec<RunRow>("SELECT * FROM runs WHERE id = ?", id).toArray()[0]; |
| 644 | if (!r) throw new HttpError(404, "run not found"); |
| 645 | return r; |
| 646 | } |
| 647 | |
| 648 | private jobRow(id: string): JobRow { |
| 649 | const j = this.sql.exec<JobRow>("SELECT * FROM jobs WHERE id = ?", id).toArray()[0]; |
| 650 | if (!j) throw new HttpError(404, "job not found"); |
| 651 | return j; |
| 652 | } |
| 653 | |
| 654 | listRuns(opts: { workflow?: string; branch?: string; status?: string; event?: string; sha?: string; prNumber?: number; limit?: number; offset?: number } = {}): { runs: RunInfo[]; total: number } { |
| 655 | const where: string[] = []; |
| 656 | const args: unknown[] = []; |
| 657 | if (opts.workflow) { |
| 658 | where.push("(workflow_path = ? OR workflow_path LIKE ?)"); |
| 659 | args.push(opts.workflow, `%/${opts.workflow}`); |
| 660 | } |
| 661 | if (opts.branch) { |
| 662 | where.push("ref = ?"); |
| 663 | args.push(`refs/heads/${opts.branch}`); |
| 664 | } |
| 665 | if (opts.status) { |
| 666 | where.push(opts.status === "completed" || opts.status === "queued" || opts.status === "in_progress" ? "status = ?" : "conclusion = ?"); |
| 667 | args.push(opts.status); |
| 668 | } |
| 669 | if (opts.event) { |
| 670 | where.push("event = ?"); |
| 671 | args.push(opts.event); |
| 672 | } |
| 673 | if (opts.sha) { |
| 674 | where.push("(sha = ? OR head_sha = ?)"); |
| 675 | args.push(opts.sha, opts.sha); |
| 676 | } |
| 677 | if (opts.prNumber) { |
| 678 | where.push("pr_number = ?"); |
| 679 | args.push(opts.prNumber); |
| 680 | } |
| 681 | const w = where.length ? `WHERE ${where.join(" AND ")}` : ""; |
| 682 | const total = this.sql.exec<{ n: number }>(`SELECT count(*) AS n FROM runs ${w}`, ...args).one().n; |
| 683 | const rows = this.sql.exec<RunRow>(`SELECT * FROM runs ${w} ORDER BY created_at DESC LIMIT ? OFFSET ?`, ...args, Math.min(opts.limit ?? 25, 100), opts.offset ?? 0).toArray(); |
| 684 | return { runs: rows.map(toRun), total }; |
| 685 | } |
| 686 | |
| 687 | getRun(id: string): RunInfo & { jobs: JobInfo[] } { |
| 688 | const run = this.runRow(id); |
| 689 | return { ...toRun(run), jobs: this.jobs(run.id) }; |
| 690 | } |
| 691 | |
| 692 | jobs(runId: string): JobInfo[] { |
| 693 | if (/^\d+$/.test(runId)) runId = this.runRow(runId).id; |
| 694 | return this.sql.exec<JobRow>("SELECT * FROM jobs WHERE run_id = ? ORDER BY rowid", runId).toArray().map(toJob); |
| 695 | } |
| 696 | |
| 697 | getJob(id: string): JobInfo { |
| 698 | return toJob(this.jobRow(id)); |
| 699 | } |
| 700 | |
| 701 | async logs(jobId: string, afterSeq = -1): Promise<{ lines: Array<LogLine & { seq: number }>; complete: boolean }> { |
| 702 | const j = this.jobRow(jobId); |
| 703 | const rows = this.sql.exec<LogLine & { seq: number }>("SELECT seq, t, step, text FROM logs WHERE job_id = ? AND seq > ? ORDER BY seq LIMIT 5000", jobId, afterSeq).toArray(); |
| 704 | if (rows.length === 0 && j.status === "completed") { |
| 705 | const archived = await getFirst(this.env.BUCKET, logKeys(this.repo.meta.repoId, jobId)); |
| 706 | if (archived && afterSeq < 0) return { lines: (await archived.json()) as Array<LogLine & { seq: number }>, complete: true }; |
| 707 | } |
| 708 | return { lines: rows, complete: j.status === "completed" }; |
| 709 | } |
| 710 | |
| 711 | private logKey(jobId: string) { |
| 712 | return logKeys(this.repo.meta.repoId, jobId)[0]; |
| 713 | } |
| 714 | |
| 715 | // ---------------------------------------------------------------- orchestration (called by RunWorkflow) |
| 716 | |
| 717 | /** |
| 718 | * The run's job graph: each job with the jobs it waits for (reusable workflow calls |
| 719 | * expanded). RunWorkflow starts a job as soon as its own needs have finished. |
| 720 | */ |
| 721 | async plan(runId: string): Promise<{ jobs: JobNode[]; error?: string; concurrencyBlocked: boolean }> { |
| 722 | const run = this.runRow(runId); |
| 723 | if (run.status === "completed") return { jobs: [], error: "run already completed", concurrencyBlocked: false }; |
| 724 | let exp: Expanded; |
| 725 | try { |
| 726 | exp = await this.expanded(run); |
| 727 | } catch (e) { |
| 728 | return { jobs: [], error: (e as Error).message, concurrencyBlocked: false }; |
| 729 | } |
| 730 | const { order, errors } = jobOrder(exp.workflow); |
| 731 | if (errors.length) return { jobs: [], error: errors.map((e) => e.message).join("; "), concurrencyBlocked: false }; |
| 732 | const blocked = |
| 733 | !!run.concurrency_group && |
| 734 | this.sql.exec<{ n: number }>("SELECT count(*) AS n FROM runs WHERE concurrency_group = ? AND status = 'in_progress' AND id != ?", run.concurrency_group, runId).one().n > 0; |
| 735 | if (!blocked) this.sql.exec("UPDATE runs SET status = 'in_progress', updated_at = ? WHERE id = ? AND status = 'queued'", now(), runId); |
| 736 | return { jobs: order.flat().map((key) => ({ key, needs: exp.workflow.jobs[key]!.needs })), concurrencyBlocked: blocked }; |
| 737 | } |
| 738 | |
| 739 | /** The run's workflow with reusable workflow calls expanded (see ./reusable.ts). */ |
| 740 | private async expanded(run: RunRow): Promise<Expanded> { |
| 741 | const wf = (await this.loadWorkflows(run.sha)).find((w) => w.path === run.workflow_path); |
| 742 | if (!wf?.workflow) throw new ReusableError(`workflow file not found or invalid: ${wf?.errors.join("; ") || run.workflow_path}`); |
| 743 | return expandReusable(wf.workflow, { repoId: null, sha: run.sha }, (uses, from) => this.loadCalled(uses, from)); |
| 744 | } |
| 745 | |
| 746 | /** Load a called workflow: `./path` from the same commit, or `owner/repo/path@ref` from a repository on this instance. */ |
| 747 | private async loadCalled(uses: string, from: Source): Promise<{ workflow: Workflow; source: Source }> { |
| 748 | const ref = parseCallRef(uses); |
| 749 | const m = this.repo.meta; |
| 750 | let source: Source; |
| 751 | let text: string | null; |
| 752 | if (ref.kind === "local" && from.repoId === null) { |
| 753 | source = from; |
| 754 | const b = await this.repo.blob(from.sha, ref.path); |
| 755 | text = b && !b.binary ? fromUtf8(b.content) : null; |
| 756 | } else { |
| 757 | let repoId: string; |
| 758 | if (ref.kind === "local") repoId = from.repoId!; |
| 759 | else if (ref.owner.toLowerCase() === m.owner.toLowerCase() && ref.repo.toLowerCase() === m.name.toLowerCase()) repoId = m.repoId; |
| 760 | else { |
| 761 | const dir = new Directory(this.env); |
| 762 | // A transferred or renamed repository's workflows are found under the old name too. |
| 763 | const target = (await dir.repo(ref.owner, ref.repo)) ?? (await dir.movedRepo(ref.owner, ref.repo)); |
| 764 | // Like GitHub: public workflows can be shared; private ones only within the same owner. |
| 765 | if (!target || (target.visibility === "private" && target.owner_id !== m.ownerId)) throw new Error(`repository ${ref.owner}/${ref.repo} not found, or its workflows are not shared with ${m.owner}/${m.name}`); |
| 766 | repoId = target.id; |
| 767 | } |
| 768 | if (repoId === m.repoId) { |
| 769 | const sha = ref.kind === "local" ? from.sha : await this.repo.resolve(ref.ref); |
| 770 | if (!sha) throw new Error(`unknown ref ${ref.kind === "remote" ? ref.ref : ""}`); |
| 771 | source = { repoId: null, sha }; |
| 772 | const b = await this.repo.blob(sha, ref.path); |
| 773 | text = b && !b.binary ? fromUtf8(b.content) : null; |
| 774 | } else { |
| 775 | const stub = this.env.REPO.get(this.env.REPO.idFromName(repoId)); |
| 776 | const sha = ref.kind === "local" ? from.sha : await stub.resolve(ref.ref); |
| 777 | if (!sha) throw new Error(`unknown ref ${ref.kind === "remote" ? ref.ref : ""} in ${ref.kind === "remote" ? `${ref.owner}/${ref.repo}` : "the called repository"}`); |
| 778 | source = { repoId, sha }; |
| 779 | const b = await stub.blob(sha, ref.path); |
| 780 | text = b && !b.binary ? fromUtf8(b.content) : null; |
| 781 | } |
| 782 | } |
| 783 | if (text === null) throw new Error(`${uses} not found`); |
| 784 | const { workflow, errors } = parseWorkflow(text, ref.path); |
| 785 | const fatal = errors.filter((e) => !isWarning(e)); |
| 786 | if (!workflow || fatal.length) throw new Error(`${uses} is invalid: ${fatal.map((e) => `${e.path}: ${e.message}`).join("; ")}`); |
| 787 | return { workflow, source }; |
| 788 | } |
| 789 | |
| 790 | /** `needs` context for a job or call: keys are as written inside its own workflow. */ |
| 791 | private async needsResults(run: RunRow, exp: Expanded, keys: string[], scope: string | null): Promise<Record<string, NeedsResult>> { |
| 792 | const out: Record<string, NeedsResult> = {}; |
| 793 | for (const key of keys) out[localKey(key, scope)] = await this.resultOf(run, exp, key); |
| 794 | return out; |
| 795 | } |
| 796 | |
| 797 | /** Status functions for the `if:` of a job or call: from the results of all its ancestors, like GitHub. */ |
| 798 | private statusOf(run: RunRow, exp: Expanded, key: string): StatusFns { |
| 799 | const results = ancestors(exp.needs, exp.needs.get(key) ?? []).map((k) => combineResults(this.rowsOf(run, exp, k))); |
| 800 | return jobStatus(results, run.conclusion === "cancelled"); |
| 801 | } |
| 802 | |
| 803 | /** The job rows of a job, or of every job inside a reusable workflow call. */ |
| 804 | private rowsOf(run: RunRow, exp: Expanded, key: string): Array<JobRow & { continueOnError: boolean }> { |
| 805 | const rows = exp.calls.has(key) |
| 806 | ? this.sql.exec<JobRow>("SELECT * FROM jobs WHERE run_id = ? AND substr(job_key, 1, ?) = ?", run.id, key.length + 1, `${key}/`).toArray() |
| 807 | : this.sql.exec<JobRow>("SELECT * FROM jobs WHERE run_id = ? AND job_key = ?", run.id, key).toArray(); |
| 808 | return rows.map((r) => ({ ...r, continueOnError: !!r.continue_on_error })); |
| 809 | } |
| 810 | |
| 811 | /** Result and outputs of a job, or of a reusable workflow call (from its `on.workflow_call.outputs`). */ |
| 812 | private async resultOf(run: RunRow, exp: Expanded, key: string): Promise<NeedsResult> { |
| 813 | const call = exp.calls.get(key); |
| 814 | const rows = this.rowsOf(run, exp, key); |
| 815 | const result = combineResults(rows); |
| 816 | let outputs: Record<string, string> = {}; |
| 817 | if (!call) for (const r of rows) Object.assign(outputs, JSON.parse(r.outputs)); |
| 818 | if (call && Object.keys(call.workflow.on.workflow_call?.outputs ?? {}).length) { |
| 819 | const jobs: Record<string, NeedsResult> = {}; |
| 820 | for (const k of Object.keys(call.workflow.jobs)) jobs[k] = await this.resultOf(run, exp, `${key}/${k}`); |
| 821 | const { inputs } = await this.callContext(run, exp, key, false); |
| 822 | const ctx: ExprContext = { github: this.githubContext(run, localKey(key, call.parent), ""), jobs, inputs, vars: this.variables() }; |
| 823 | outputs = {}; |
| 824 | for (const [name, d] of Object.entries(call.workflow.on.workflow_call!.outputs!)) { |
| 825 | try { |
| 826 | const v = evaluateValue(d.value, ctx); |
| 827 | outputs[name] = v === undefined || v === null ? "" : typeof v === "object" ? JSON.stringify(v) : String(v); |
| 828 | } catch { |
| 829 | outputs[name] = ""; |
| 830 | } |
| 831 | } |
| 832 | } |
| 833 | return { result, outputs }; |
| 834 | } |
| 835 | |
| 836 | /** |
| 837 | * `inputs` and `secrets` inside a reusable workflow call (or at the top level when |
| 838 | * `callKey` is null): inputs from the caller's `with` and the declared defaults, |
| 839 | * secrets from `secrets: inherit` or the caller's mapping. |
| 840 | */ |
| 841 | private async callContext(run: RunRow, exp: Expanded, callKey: string | null, withSecrets: boolean): Promise<{ inputs: Record<string, unknown>; secrets: Record<string, string> }> { |
| 842 | if (callKey === null) return { inputs: JSON.parse(run.inputs), secrets: withSecrets ? { ...(await this.orgSecrets()), ...(await this.repoSecrets()) } : {} }; |
| 843 | const call = exp.calls.get(callKey)!; |
| 844 | const parent = await this.callContext(run, exp, call.parent, withSecrets); |
| 845 | const ctx: ExprContext = { github: this.githubContext(run, localKey(callKey, call.parent), ""), needs: await this.needsResults(run, exp, call.needs, call.parent), inputs: parent.inputs, vars: this.variables() }; |
| 846 | const inputs: Record<string, unknown> = {}; |
| 847 | for (const [k, d] of Object.entries(call.workflow.on.workflow_call?.inputs ?? {})) { |
| 848 | let v = call.job.with && k in call.job.with ? evaluateValue(call.job.with[k], ctx) : evaluateValue(d.default, ctx); |
| 849 | if (v === undefined || v === null || v === "") { |
| 850 | if (d.required) throw new ReusableError(`jobs.${callKey}: input '${k}' is required by ${call.job.uses}`); |
| 851 | v = d.type === "boolean" ? false : d.type === "number" ? 0 : ""; |
| 852 | } |
| 853 | inputs[k] = d.type === "boolean" ? v === true || v === "true" : d.type === "number" ? Number(v) : v; |
| 854 | } |
| 855 | const secrets: Record<string, string> = {}; |
| 856 | if (withSecrets) { |
| 857 | if (call.job.secrets === "inherit") Object.assign(secrets, parent.secrets); |
| 858 | else for (const [name, expr] of Object.entries(call.job.secrets ?? {})) secrets[name.toUpperCase()] = String(evaluateValue(expr, { ...ctx, secrets: parent.secrets }) ?? ""); |
| 859 | for (const [name, d] of Object.entries(call.workflow.on.workflow_call?.secrets ?? {})) { |
| 860 | if (d.required && !secrets[name.toUpperCase()]) throw new ReusableError(`jobs.${callKey}: secret '${name}' is required by ${call.job.uses}`); |
| 861 | } |
| 862 | } |
| 863 | return { inputs, secrets }; |
| 864 | } |
| 865 | |
| 866 | /** |
| 867 | * Start every matrix instance of a job. Returns the ids the workflow must wait |
| 868 | * for (instances beyond max-parallel are queued and started as others finish). |
| 869 | */ |
| 870 | async startJobKey(runId: string, jobKey: string): Promise<{ jobIds: string[] }> { |
| 871 | const run = this.runRow(runId); |
| 872 | const exp = await this.expanded(run); |
| 873 | const job = exp.workflow.jobs[jobKey]!; |
| 874 | const owner = exp.workflowOf.get(jobKey)!; |
| 875 | const parent = exp.parentOf.get(jobKey) ?? null; |
| 876 | const chain: string[] = []; |
| 877 | for (let c = parent; c; c = exp.calls.get(c)!.parent) chain.unshift(c); |
| 878 | const prefix = chain.map((c) => exp.calls.get(c)!.job.name ?? localKey(c, exp.calls.get(c)!.parent)).join(" / "); |
| 879 | const label = (name: string) => (prefix ? `${prefix} / ${name}` : name); |
| 880 | const fail = (name: string, message: string, conclusion: Conclusion = "failure") => { |
| 881 | const id = newId(); |
| 882 | const t = now(); |
| 883 | this.sql.exec("INSERT INTO jobs (id, run_id, job_key, name, status, conclusion, started_at, completed_at, failure) VALUES (?, ?, ?, ?, 'completed', ?, ?, ?, ?)", id, runId, jobKey, name, conclusion, t, t, conclusion === "failure" ? JSON.stringify({ message }) : null); |
| 884 | this.repo.broadcast(`run:${runId}`, { action: "job", jobId: id }); |
| 885 | return { jobIds: [] as string[] }; |
| 886 | }; |
| 887 | const short = localKey(jobKey, parent); |
| 888 | let needs: Record<string, NeedsResult>; |
| 889 | let baseCtx: ExprContext; |
| 890 | let shouldRun: boolean; |
| 891 | try { |
| 892 | // The callers decide first: a skipped call, or one whose ancestors failed or were skipped, skips everything inside it. |
| 893 | for (const c of chain) { |
| 894 | const call = exp.calls.get(c)!; |
| 895 | const callNeeds = await this.needsResults(run, exp, call.needs, call.parent); |
| 896 | const outer = await this.callContext(run, exp, call.parent, false); |
| 897 | const callCtx: ExprContext = { github: this.githubContext(run, localKey(c, call.parent), ""), needs: callNeeds, inputs: outer.inputs, vars: this.variables() }; |
| 898 | if (!evaluateIf(call.job.if, callCtx, this.statusOf(run, exp, c))) return fail(label(job.name ?? short), "", "skipped"); |
| 899 | } |
| 900 | // Resolve the call's inputs and secrets now, so a missing required one fails here rather than in the runner. |
| 901 | if (parent) await this.callContext(run, exp, parent, true); |
| 902 | needs = await this.needsResults(run, exp, exp.needs.get(jobKey) ?? [], parent); |
| 903 | baseCtx = { github: this.githubContext(run, short, ""), needs, inputs: (await this.callContext(run, exp, parent, false)).inputs, vars: this.variables(), env: owner.env }; |
| 904 | shouldRun = evaluateIf(job.if, baseCtx, this.statusOf(run, exp, jobKey)); |
| 905 | } catch (e) { |
| 906 | return fail(label(job.name ?? short), e instanceof ReusableError ? e.message : `Error evaluating 'if': ${(e as Error).message}`); |
| 907 | } |
| 908 | if (!shouldRun) return fail(label(job.name ?? short), "", "skipped"); |
| 909 | |
| 910 | let combos: Array<Record<string, unknown>>; |
| 911 | try { |
| 912 | combos = expandMatrix(job.strategy?.matrix, baseCtx); |
| 913 | } catch (e) { |
| 914 | return fail(label(job.name ?? short), `Error expanding matrix: ${(e as Error).message}`); |
| 915 | } |
| 916 | if (combos.length > 256) return fail(label(job.name ?? short), "A matrix may produce at most 256 jobs."); |
| 917 | const failFast = evaluateValue(job.strategy?.failFast ?? true, baseCtx) !== false; |
| 918 | const maxParallel = Number(evaluateValue(job.strategy?.maxParallel ?? combos.length, baseCtx)) || combos.length; |
| 919 | const ids: string[] = []; |
| 920 | combos.forEach((matrix, i) => { |
| 921 | // Like GitHub, name, runs-on, timeout-minutes, continue-on-error and concurrency are evaluated for each matrix leg. |
| 922 | const ctx = { ...baseCtx, matrix, strategy: { "fail-fast": failFast, "job-index": i, "job-total": combos.length, "max-parallel": maxParallel } }; |
| 923 | let name = short; |
| 924 | if (!job.name && Object.keys(matrix).length) name = `${short} (${Object.values(matrix).map((v) => (typeof v === "object" ? JSON.stringify(v) : String(v))).join(", ")})`; |
| 925 | let leg: { labels: string[]; timeout: number; continueOnError: boolean; group: string | null; cancel: boolean; environment: string | null }; |
| 926 | try { |
| 927 | if (job.name) name = interpolate(job.name, ctx); |
| 928 | const continueOnError = evaluateValue(job.continueOnError, ctx); |
| 929 | const cancel = job.concurrency ? evaluateValue(job.concurrency.cancelInProgress, ctx) : false; |
| 930 | leg = { |
| 931 | labels: runnerLabels(job.runsOn, ctx), |
| 932 | timeout: Number(evaluateValue(job.timeoutMinutes, ctx)) || 360, |
| 933 | continueOnError: continueOnError === true || continueOnError === "true", |
| 934 | group: (job.concurrency ? interpolate(job.concurrency.group, ctx).trim() : "") || null, |
| 935 | cancel: cancel === true || cancel === "true", |
| 936 | // Evaluated once, here: its protection rules gate the launch, and runnerSpec deploys to this same name. |
| 937 | environment: job.environment ? interpolate(job.environment.name, ctx).trim() : null, |
| 938 | }; |
| 939 | } catch (e) { |
| 940 | fail(label(name), `Error evaluating the job for this matrix combination: ${(e as Error).message}`); |
| 941 | return; |
| 942 | } |
| 943 | const unsupported = leg.labels.filter((l) => !SUPPORTED_LABEL.test(l)); |
| 944 | const selfHosted = leg.labels.some((l) => l.toLowerCase() === "self-hosted"); |
| 945 | if (!leg.labels.length || (unsupported.length && !selfHosted)) { |
| 946 | fail(label(name), `No Diffinery runner matches runs-on: ${unsupported.join(", ") || "(no labels)"}. Linux (ubuntu-*) runners are hosted; anything else needs runs-on: [self-hosted, ...] and a registered runner.`); |
| 947 | return; |
| 948 | } |
| 949 | const id = newId(); |
| 950 | this.sql.exec( |
| 951 | "INSERT INTO jobs (id, run_id, job_key, name, status, matrix, strategy, timeout_minutes, labels, continue_on_error, concurrency_group, concurrency_cancel, environment) VALUES (?, ?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?, ?, ?)", |
| 952 | id, |
| 953 | runId, |
| 954 | jobKey, |
| 955 | label(name), |
| 956 | JSON.stringify(matrix), |
| 957 | JSON.stringify(ctx.strategy), |
| 958 | Math.min(leg.timeout, 360), |
| 959 | selfHosted ? JSON.stringify(leg.labels.map((l) => l.toLowerCase())) : null, |
| 960 | leg.continueOnError ? 1 : 0, |
| 961 | leg.group, |
| 962 | leg.cancel ? 1 : 0, |
| 963 | leg.environment, |
| 964 | ); |
| 965 | ids.push(id); |
| 966 | }); |
| 967 | for (const id of ids.slice(0, maxParallel)) await this.launch(id); |
| 968 | this.repo.broadcast(`run:${runId}`, { action: "jobs" }); |
| 969 | return { jobIds: ids }; |
| 970 | } |
| 971 | |
| 972 | private checkName(run: RunRow, job: JobRow): string { |
| 973 | return `${run.workflow_name} / ${job.name}`; |
| 974 | } |
| 975 | |
| 976 | private async launch(jobId: string): Promise<void> { |
| 977 | const job = this.jobRow(jobId); |
| 978 | if (job.status === "completed" || !(await this.gate(job)) || !(await this.concurrencySlot(job))) return; |
| 979 | const run = this.runRow(job.run_id); |
| 980 | const m = this.repo.meta; |
| 981 | if (job.labels) { |
| 982 | // Self-hosted: park the job until a registered runner with matching labels claims it. |
| 983 | this.sql.exec("UPDATE jobs SET status = 'waiting', container = NULL WHERE id = ?", jobId); |
| 984 | this.repo.upsertCheck({ sha: run.head_sha ?? run.sha, name: this.checkName(run, job), status: "queued", runId: run.id, jobId, detailsUrl: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}/actions/runs/${run.id}/job/${jobId}` }); |
| 985 | return; |
| 986 | } |
| 987 | // The account that owns the repository pays (lib/quotas.ts): past its monthly minutes the job |
| 988 | // fails; past its hosted jobs at once it waits (fleet_held = 2, retried by the alarm) for one to end. |
| 989 | const quota = await mayStart(this.env, m.ownerId, "job").catch((e: unknown) => { |
| 990 | console.error("quota check failed", e); |
| 991 | return { ok: true } as const; |
| 992 | }); |
| 993 | if (!quota.ok) { |
| 994 | if (!quota.wait) return this.completeJob(jobId, { conclusion: "failure", outputs: {}, failure: { message: `The job was not started: ${quota.message}.` } }); |
| 995 | this.sql.exec("UPDATE jobs SET status = 'queued', fleet_held = 2 WHERE id = ?", jobId); |
| 996 | this.repo.upsertCheck({ sha: run.head_sha ?? run.sha, name: this.checkName(run, job), status: "queued", runId: run.id, jobId, detailsUrl: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}/actions/runs/${run.id}/job/${jobId}` }); |
| 997 | this.repo.armAlarm(null); |
| 998 | return; |
| 999 | } |
| 1000 | // One repository can't take the whole fleet: past its share, the job waits for one of its own to finish. |
| 1001 | const busy = this.sql.exec<{ n: number }>("SELECT count(*) AS n FROM jobs WHERE labels IS NULL AND container IS NOT NULL AND status IN ('waiting', 'in_progress') AND id != ?", jobId).one().n; |
| 1002 | if (busy >= MAX_HOSTED_JOBS) { |
| 1003 | this.sql.exec("UPDATE jobs SET status = 'queued', fleet_held = 1 WHERE id = ?", jobId); |
| 1004 | this.repo.upsertCheck({ sha: run.head_sha ?? run.sha, name: this.checkName(run, job), status: "queued", runId: run.id, jobId, detailsUrl: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}/actions/runs/${run.id}/job/${jobId}` }); |
| 1005 | return; |
| 1006 | } |
| 1007 | this.sql.exec("UPDATE jobs SET fleet_held = 0 WHERE id = ?", jobId); |
| 1008 | const token = `djt_${m.repoId}_${jobId}_${randomToken(24)}`; |
| 1009 | const container = `job-${jobId}`; |
| 1010 | this.sql.exec("UPDATE jobs SET status = 'waiting', token_hash = ?, container = ? WHERE id = ?", await sha256(token), container, jobId); |
| 1011 | await startCompute(this.env, { id: jobId, accountId: m.ownerId, repoId: m.repoId, kind: "job" }).catch((e: unknown) => console.error("compute usage failed", e)); |
| 1012 | this.repo.upsertCheck({ sha: run.head_sha ?? run.sha, name: this.checkName(run, job), status: "queued", runId: run.id, jobId, detailsUrl: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}/actions/runs/${run.id}/job/${jobId}` }); |
| 1013 | try { |
| 1014 | const stub = this.env.RUNNER.get(this.env.RUNNER.idFromName(container)); |
| 1015 | await stub.startJob({ repoId: m.repoId, jobId, token, apiUrl: this.env.PUBLIC_URL }); |
| 1016 | } catch (e) { |
| 1017 | const message = (e as Error).message; |
| 1018 | if (message.includes(NO_INSTANCE)) return this.placementFailed(jobId, message); |
| 1019 | await this.completeJob(jobId, { conclusion: "failure", outputs: {}, failure: { message: `Could not start a runner: ${message}` } }); |
| 1020 | } |
| 1021 | } |
| 1022 | |
| 1023 | |
| 1024 | // ---------------------------------------------------------------- environment protection rules |
| 1025 | |
| 1026 | private jobUrl(run: RunRow, jobId: string): string { |
| 1027 | const m = this.repo.meta; |
| 1028 | return `${this.env.PUBLIC_URL}/${m.owner}/${m.name}/actions/runs/${run.id}/job/${jobId}`; |
| 1029 | } |
| 1030 | |
| 1031 | /** |
| 1032 | * Whose run this is, for prevent_self_review, as account ids (logins can be renamed): who started |
| 1033 | * it and its earlier attempts, and for each of them that is an agent, the account that owns it. |
| 1034 | */ |
| 1035 | async selfIds(run: RunRow): Promise<Set<string>> { |
| 1036 | const actor = JSON.parse(run.actor) as Actor; |
| 1037 | const ids = new Set([actor.id, ...((run.prior_actors ? JSON.parse(run.prior_actors) : []) as string[])].filter(Boolean)); |
| 1038 | const dir = new Directory(this.env); |
| 1039 | for (const id of [...ids]) { |
| 1040 | const a = await dir.account(id).catch(() => null); |
| 1041 | if (a?.kind === "agent" && a.owner_id) ids.add(a.owner_id); |
| 1042 | } |
| 1043 | return ids; |
| 1044 | } |
| 1045 | |
| 1046 | /** |
| 1047 | * A job whose environment has required reviewers or a wait timer waits here, before a token is |
| 1048 | * minted or a runner started, in the status `approval`: no concurrency slot, no hosted slot. |
| 1049 | * Returns whether the job may go on to start. The branch policy is checked first, so a ref that |
| 1050 | * may never deploy fails at once instead of asking anyone. |
| 1051 | */ |
| 1052 | private async gate(job: JobRow): Promise<boolean> { |
| 1053 | if (!job.environment || job.gate_state === "released") return true; |
| 1054 | const rules = this.repo.deploys.protection(job.environment); |
| 1055 | if (!rules) { |
| 1056 | if (job.status === "approval") this.release(job); |
| 1057 | return true; |
| 1058 | } |
| 1059 | const run = this.runRow(job.run_id); |
| 1060 | const fail = async (message: string) => { |
| 1061 | await this.completeJob(job.id, { conclusion: "failure", outputs: {}, failure: { message } }); |
| 1062 | return false; |
| 1063 | }; |
| 1064 | if (!this.repo.deploys.refAllowed(job.environment, run.ref)) return fail(`${run.ref.replace(/^refs\/(heads|tags)\//, "")} is not allowed to deploy to ${job.environment} due to environment protection rules.`); |
| 1065 | const decision = rules.reviewers.length ? this.repo.deploys.decision(run.id, job.environment) : null; |
| 1066 | if (decision === "rejected") { |
| 1067 | const r = this.repo.deploys.reviews(run.id).find((x) => x.environment.toLowerCase() === job.environment!.toLowerCase() && x.state === "rejected")!; |
| 1068 | return fail(`The deployment to ${job.environment} was rejected by ${r.user.login}${r.comment ? `: ${r.comment}` : "."}`); |
| 1069 | } |
| 1070 | let since = job.gate_since; |
| 1071 | if (job.status !== "approval") { |
| 1072 | since = now(); |
| 1073 | this.sql.exec("UPDATE jobs SET status = 'approval', gate_since = ? WHERE id = ?", since, job.id); |
| 1074 | this.sql.exec("UPDATE runs SET status = 'waiting', updated_at = ? WHERE id = ? AND status != 'completed'", since, run.id); |
| 1075 | const actor = JSON.parse(run.actor) as Actor; |
| 1076 | this.repo.deploys.jobWaiting({ id: job.id, ref: run.ref, sha: run.sha, logUrl: this.jobUrl(run, job.id), actor: run.agent_origin ? { ...actor, kind: "agent" } : actor }, job.environment); |
| 1077 | this.repo.upsertCheck({ sha: run.head_sha ?? run.sha, name: this.checkName(run, job), status: "queued", runId: run.id, jobId: job.id, detailsUrl: this.jobUrl(run, job.id) }); |
| 1078 | // Reviewers hear about it once per run and environment, from its first job to wait. |
| 1079 | const first = !this.sql.exec("SELECT 1 FROM jobs WHERE run_id = ? AND environment = ? COLLATE NOCASE AND gate_since IS NOT NULL AND id != ?", run.id, job.environment, job.id).toArray().length; |
| 1080 | if (first && rules.reviewers.length && !decision) this.emitReview("requested", run, job.environment, rules.reviewers, actor, null, ""); |
| 1081 | this.repo.broadcast(`run:${run.id}`, { action: "job", jobId: job.id }); |
| 1082 | this.repo.broadcast("runs", { action: "waiting", runId: run.id }); |
| 1083 | } |
| 1084 | const timerEnds = Date.parse(since!) + rules.waitTimer * 60_000; |
| 1085 | if ((rules.reviewers.length && decision !== "approved") || Date.now() < timerEnds) return false; |
| 1086 | this.release({ ...job, status: "approval" }); |
| 1087 | return true; |
| 1088 | } |
| 1089 | |
| 1090 | /** The job's protection rules passed: it goes on to start, and its run stops waiting once no other job does. */ |
| 1091 | private release(job: JobRow): void { |
| 1092 | this.sql.exec("UPDATE jobs SET gate_state = 'released', status = 'queued' WHERE id = ?", job.id); |
| 1093 | this.syncWaiting(job.run_id); |
| 1094 | } |
| 1095 | |
| 1096 | private syncWaiting(runId: string): void { |
| 1097 | if (this.sql.exec("SELECT 1 FROM jobs WHERE run_id = ? AND status = 'approval'", runId).toArray().length) return; |
| 1098 | this.sql.exec("UPDATE runs SET status = 'in_progress', updated_at = ? WHERE id = ? AND status = 'waiting'", now(), runId); |
| 1099 | } |
| 1100 | |
| 1101 | private async wakeGate(runId: string, jobId: string): Promise<void> { |
| 1102 | try { |
| 1103 | await (await this.env.RUN_WORKFLOW.get(runId)).sendEvent({ type: `gate-${jobId}`, payload: {} }); |
| 1104 | } catch (e) { |
| 1105 | console.error("failed to wake a job waiting for approval", e); |
| 1106 | } |
| 1107 | } |
| 1108 | |
| 1109 | private emitReview(action: "requested" | "approved" | "rejected", run: RunRow, environment: string, reviewers: Reviewer[], by: Actor, state: string | null, comment: string): void { |
| 1110 | const m = this.repo.meta; |
| 1111 | const url = `${this.env.PUBLIC_URL}/${m.owner}/${m.name}/actions/runs/${run.id}`; |
| 1112 | const ids = Object.fromEntries(this.repo.deploys.environments().map((e) => [e.name.toLowerCase(), e.id])); |
| 1113 | this.repo.emit({ |
| 1114 | type: "deployment_review", |
| 1115 | action, |
| 1116 | repoId: m.repoId, |
| 1117 | owner: m.owner, |
| 1118 | repo: m.name, |
| 1119 | payload: { |
| 1120 | since: now(), |
| 1121 | environment: environment, |
| 1122 | environment_id: ids[environment.toLowerCase()] ?? null, |
| 1123 | comment, |
| 1124 | ...(state ? { state } : {}), |
| 1125 | workflow_run: { id: run.id, run_number: run.run_number, name: run.workflow_name, display_title: run.display_title, head_sha: run.head_sha ?? run.sha, head_branch: run.ref.replace(/^refs\/(heads|tags)\//, ""), html_url: url }, |
| 1126 | reviewers: reviewers.map((r) => ({ type: "User", reviewer: { login: r.login, id: r.id } })), |
| 1127 | ...(action === "requested" ? { requestor: { login: by.login } } : { approver: { login: by.login } }), |
| 1128 | }, |
| 1129 | actor: by, |
| 1130 | at: now(), |
| 1131 | }); |
| 1132 | } |
| 1133 | |
| 1134 | /** |
| 1135 | * Where a job waiting on its environment stands, for the run's Workflow: what it waits for and |
| 1136 | * until when. Re-checks the rules first, so a job whose time came starts here. |
| 1137 | */ |
| 1138 | async jobGate(jobId: string): Promise<{ status: JobRow["status"]; wait: "review" | "timer" | null; until: string | null }> { |
| 1139 | let job = this.jobRow(jobId); |
| 1140 | if (job.status === "approval") await this.launch(jobId); |
| 1141 | job = this.jobRow(jobId); |
| 1142 | if (job.status !== "approval") return { status: job.status, wait: null, until: null }; |
| 1143 | const rules = this.repo.deploys.protection(job.environment!) ?? { reviewers: [], waitTimer: 0, preventSelfReview: false }; |
| 1144 | const since = Date.parse(job.gate_since!); |
| 1145 | const deadline = since + APPROVAL_DAYS * 86400_000; |
| 1146 | if (Date.now() >= deadline) { |
| 1147 | await this.completeJob(jobId, { conclusion: "failure", outputs: {}, failure: { message: `The deployment to ${job.environment} wasn't approved within ${APPROVAL_DAYS} days.` } }); |
| 1148 | return { status: "completed", wait: null, until: null }; |
| 1149 | } |
| 1150 | const decided = this.repo.deploys.decision(job.run_id, job.environment!) === "approved"; |
| 1151 | if (rules.reviewers.length && !decided) return { status: "approval", wait: "review", until: new Date(deadline).toISOString() }; |
| 1152 | return { status: "approval", wait: "timer", until: new Date(since + rules.waitTimer * 60_000).toISOString() }; |
| 1153 | } |
| 1154 | |
| 1155 | /** The environments a run's jobs wait on, with who may approve them (GitHub's pending deployments). */ |
| 1156 | async pendingDeployments(runId: string, viewer: { id: string; login: string; mayReview?: boolean } | null): Promise<PendingDeployment[]> { |
| 1157 | const run = this.runRow(runId); |
| 1158 | const waiting = this.sql.exec<{ environment: string; since: string }>("SELECT environment, MIN(gate_since) AS since FROM jobs WHERE run_id = ? AND status = 'approval' GROUP BY environment COLLATE NOCASE", run.id).toArray(); |
| 1159 | const self = viewer ? await this.selfIds(run) : new Set<string>(); |
| 1160 | return waiting.flatMap((w) => { |
| 1161 | const env = this.repo.deploys.environments().find((e) => e.name.toLowerCase() === w.environment.toLowerCase()); |
| 1162 | if (!env) return []; |
| 1163 | const p = env.protection; |
| 1164 | const decided = this.repo.deploys.decision(run.id, env.name); |
| 1165 | // A listed reviewer who lost access to the repository no longer decides (the caller says so in mayReview). |
| 1166 | const listed = !!viewer && viewer.mayReview !== false && p.reviewers.some((r) => r.id === viewer.id); |
| 1167 | const selfBlocked = !!viewer && p.preventSelfReview && self.has(viewer.id); |
| 1168 | return [ |
| 1169 | { |
| 1170 | environment: { id: env.id, name: env.name }, |
| 1171 | waitTimer: p.waitTimer, |
| 1172 | waitTimerStartedAt: w.since, |
| 1173 | reviewers: p.reviewers, |
| 1174 | preventSelfReview: p.preventSelfReview, |
| 1175 | needsReview: p.reviewers.length > 0 && decided !== "approved", |
| 1176 | currentUserCanApprove: listed && !selfBlocked && p.reviewers.length > 0 && !decided, |
| 1177 | selfBlocked, |
| 1178 | }, |
| 1179 | ]; |
| 1180 | }); |
| 1181 | } |
| 1182 | |
| 1183 | /** |
| 1184 | * A required reviewer approves or rejects environments a run waits on. The caller checked that |
| 1185 | * `reviewer` is a person signed in or using a write token (never an agent or a job); here the |
| 1186 | * reviewer must be listed on every environment, and not whoever started the run when the |
| 1187 | * environment prevents self-review. One approval is enough. Approved jobs start once their |
| 1188 | * wait timer has also passed; rejected ones fail. |
| 1189 | */ |
| 1190 | async reviewDeployments(runId: string, input: { environmentIds: number[]; state: "approved" | "rejected"; comment: string }, reviewer: { id: string; login: string; mayReview: boolean }): Promise<{ reviews: DeploymentReview[]; deployments: Deployment[] }> { |
| 1191 | const run = this.runRow(runId); |
| 1192 | if (!reviewer.mayReview) throw new HttpError(403, "only people with access to this repository review its deployments"); |
| 1193 | if (!input.environmentIds.length) throw new HttpError(422, "environment_ids must name at least one environment"); |
| 1194 | const pending = await this.pendingDeployments(run.id, reviewer); |
| 1195 | const chosen = input.environmentIds.map((id) => { |
| 1196 | const p = pending.find((x) => x.environment.id === id); |
| 1197 | if (!p || !p.needsReview) throw new HttpError(422, `environment ${id} isn't waiting for a review in this run`); |
| 1198 | if (p.selfBlocked) throw new HttpError(403, `${p.environment.name} doesn't let whoever started a run approve it`); |
| 1199 | if (!p.currentUserCanApprove) throw new HttpError(403, `you aren't a required reviewer for ${p.environment.name}`); |
| 1200 | return p; |
| 1201 | }); |
| 1202 | const by: Actor = { id: reviewer.id, login: reviewer.login, kind: "user" }; |
| 1203 | const out: DeploymentReview[] = []; |
| 1204 | const jobIds: string[] = []; |
| 1205 | for (const p of chosen) { |
| 1206 | out.push(this.repo.deploys.addReview(run.id, p.environment.name, input.state, input.comment, reviewer)); |
| 1207 | this.emitReview(input.state, run, p.environment.name, p.reviewers, by, input.state, input.comment); |
| 1208 | const jobs = this.sql.exec<JobRow>("SELECT * FROM jobs WHERE run_id = ? AND status = 'approval' AND environment = ? COLLATE NOCASE", run.id, p.environment.name).toArray(); |
| 1209 | for (const j of jobs) { |
| 1210 | jobIds.push(j.id); |
| 1211 | await this.launch(j.id); |
| 1212 | await this.wakeGate(run.id, j.id); |
| 1213 | } |
| 1214 | } |
| 1215 | this.repo.broadcast(`run:${run.id}`, { action: "reviewed" }); |
| 1216 | return { reviews: out, deployments: this.repo.deploys.jobDeployments(jobIds) }; |
| 1217 | } |
| 1218 | |
| 1219 | deploymentReviews(runId: string): DeploymentReview[] { |
| 1220 | return this.repo.deploys.reviews(this.runRow(runId).id); |
| 1221 | } |
| 1222 | |
| 1223 | /** |
| 1224 | * The job's container got no machine (its start failed, or it stopped before the runner reported |
| 1225 | * in). Put the job back in line and have the run's workflow start it again after a pause, or fail it |
| 1226 | * once it has waited MAX_PLACEMENT_TRIES times. |
| 1227 | */ |
| 1228 | private async placementFailed(jobId: string, why: string): Promise<void> { |
| 1229 | const job = this.jobRow(jobId); |
| 1230 | if (job.status === "completed") return; |
| 1231 | const tries = job.placement_tries + 1; |
| 1232 | if (tries > MAX_PLACEMENT_TRIES) { |
| 1233 | await this.completeJob(jobId, { conclusion: "failure", outputs: {}, failure: { message: `No hosted runner could be started: Diffinery tried ${MAX_PLACEMENT_TRIES} times over about half an hour (${why}). Re-run the job to try again.` } }); |
| 1234 | return; |
| 1235 | } |
| 1236 | this.sql.exec("UPDATE jobs SET status = 'queued', container = NULL, token_hash = NULL, placement_tries = ? WHERE id = ?", tries, jobId); |
| 1237 | // Not running while it waits: it holds none of the account's hosted jobs and uses no minutes. |
| 1238 | await endCompute(this.env, jobId).catch((e: unknown) => console.error("compute usage failed", e)); |
| 1239 | this.repo.broadcast(`run:${job.run_id}`, { action: "job", jobId }); |
| 1240 | try { |
| 1241 | const instance = await this.env.RUN_WORKFLOW.get(job.run_id); |
| 1242 | await instance.sendEvent({ type: `job-${jobId}`, payload: { retryInSeconds: placementDelay(tries) } }); |
| 1243 | } catch (e) { |
| 1244 | console.error("could not ask the run's workflow to start the job again", e); |
| 1245 | await this.completeJob(jobId, { conclusion: "failure", outputs: {}, failure: { message: `Could not start a runner: ${why}` } }); |
| 1246 | } |
| 1247 | } |
| 1248 | |
| 1249 | /** The run's workflow starts a job again after placementFailed put it back in line. */ |
| 1250 | async relaunchJob(jobId: string): Promise<void> { |
| 1251 | if (this.jobRow(jobId).status === "queued") await this.launch(jobId); |
| 1252 | } |
| 1253 | |
| 1254 | /** |
| 1255 | * Job-level `concurrency`, like GitHub: at most one job of a group runs at a time in |
| 1256 | * the repository. With cancel-in-progress the newcomer cancels the group's other jobs; |
| 1257 | * without it the newcomer waits as `pending` and replaces any older pending job. |
| 1258 | * Returns whether the job may start now. |
| 1259 | */ |
| 1260 | private async concurrencySlot(job: JobRow): Promise<boolean> { |
| 1261 | if (!job.concurrency_group) return true; |
| 1262 | const others = this.sql.exec<JobRow>("SELECT * FROM jobs WHERE concurrency_group = ? AND id != ? AND status IN ('pending', 'waiting', 'in_progress') ORDER BY rowid", job.concurrency_group, job.id).toArray(); |
| 1263 | const pending = others.filter((o) => o.status === "pending"); |
| 1264 | const running = others.filter((o) => o.status !== "pending"); |
| 1265 | const failure = { message: `Canceling since a higher priority waiting request for '${job.concurrency_group}' exists` }; |
| 1266 | if (running.length && !job.concurrency_cancel) { |
| 1267 | this.sql.exec("UPDATE jobs SET status = 'pending' WHERE id = ?", job.id); |
| 1268 | this.repo.broadcast(`run:${job.run_id}`, { action: "job", jobId: job.id }); |
| 1269 | for (const o of pending) await this.completeJob(o.id, { conclusion: "cancelled", outputs: {}, failure }); |
| 1270 | return false; |
| 1271 | } |
| 1272 | // Take the group before cancelling anything, so a job that starts meanwhile waits for this one. |
| 1273 | this.sql.exec("UPDATE jobs SET status = 'waiting' WHERE id = ?", job.id); |
| 1274 | for (const o of [...pending, ...running]) { |
| 1275 | await this.stopContainer(o); |
| 1276 | await this.completeJob(o.id, { conclusion: "cancelled", outputs: {}, failure }); |
| 1277 | } |
| 1278 | return true; |
| 1279 | } |
| 1280 | |
| 1281 | /** Whether jobs wait on the account's hosted-job quota (RepoDO retries them on its alarm). */ |
| 1282 | hasAccountHeldJobs(): boolean { |
| 1283 | return this.sql.exec<{ n: number }>("SELECT count(*) AS n FROM jobs WHERE fleet_held = 2 AND status = 'queued'").one().n > 0; |
| 1284 | } |
| 1285 | |
| 1286 | /** Try again to start jobs waiting on the account's hosted-job quota, oldest first, until one waits again. */ |
| 1287 | async releaseAccountHeld(): Promise<void> { |
| 1288 | for (const { id } of this.sql.exec<{ id: string }>("SELECT id FROM jobs WHERE fleet_held = 2 AND status = 'queued' ORDER BY rowid").toArray()) { |
| 1289 | await this.launch(id); |
| 1290 | if (this.jobRow(id).fleet_held === 2) return; |
| 1291 | } |
| 1292 | } |
| 1293 | |
| 1294 | async jobTimedOut(jobId: string): Promise<void> { |
| 1295 | const j = this.jobRow(jobId); |
| 1296 | if (j.status === "completed") return; |
| 1297 | if (j.status === "queued" && j.fleet_held === 2) { |
| 1298 | await this.completeJob(jobId, { conclusion: "failure", outputs: {}, failure: { message: "The job waited 6 hours for one of this account's hosted jobs to finish, and was not started." } }, "timed_out"); |
| 1299 | return; |
| 1300 | } |
| 1301 | await this.stopContainer(j); |
| 1302 | await this.completeJob(jobId, { conclusion: "failure", outputs: {}, failure: { message: `The job exceeded its timeout of ${j.timeout_minutes} minutes.` } }, "timed_out"); |
| 1303 | } |
| 1304 | |
| 1305 | async containerStopped(jobId: string, exitCode: number | null): Promise<void> { |
| 1306 | const j = this.sql.exec<JobRow>("SELECT * FROM jobs WHERE id = ?", jobId).toArray()[0]; |
| 1307 | if (!j || j.status === "completed") return; |
| 1308 | // Stopped while still waiting for its runner, without an exit code: the container never got a machine. |
| 1309 | if (j.status === "waiting" && !j.labels && !exitCode) return this.placementFailed(jobId, "the container stopped before the runner started"); |
| 1310 | await this.completeJob(jobId, { conclusion: "failure", outputs: {}, failure: { message: exitCode ? `The runner stopped unexpectedly (exit code ${exitCode}).` : "The runner's container stopped before the job finished, without an exit code (the platform restarted or replaced it)." } }); |
| 1311 | } |
| 1312 | |
| 1313 | /** The oldest waiting job whose runs-on labels the runner all offers, handed to it with a fresh job token. */ |
| 1314 | private async assignJob(runnerName: string, runnerLabels: string[], runnerId: number | null): Promise<{ jobId: string; token: string; runId: string; name: string } | null> { |
| 1315 | const offered = new Set(["self-hosted", ...runnerLabels.map((l) => l.toLowerCase())]); |
| 1316 | const waiting = this.sql |
| 1317 | .exec<JobRow>("SELECT j.* FROM jobs j JOIN runs r ON r.id = j.run_id WHERE j.status = 'waiting' AND j.labels IS NOT NULL AND j.container IS NULL AND r.status != 'completed' ORDER BY r.created_at, j.rowid") |
| 1318 | .toArray(); |
| 1319 | const job = waiting.find((j) => (JSON.parse(j.labels!) as string[]).every((l) => offered.has(l))); |
| 1320 | if (!job) return null; |
| 1321 | const m = this.repo.meta; |
| 1322 | const token = `djt_${m.repoId}_${job.id}_${randomToken(24)}`; |
| 1323 | const container = `self-hosted:${runnerName.slice(0, 64)}`; |
| 1324 | this.sql.exec("UPDATE jobs SET token_hash = ?, container = ?, runner_id = ? WHERE id = ? AND container IS NULL", await sha256(token), container, runnerId, job.id); |
| 1325 | this.repo.broadcast(`run:${job.run_id}`, { action: "job", jobId: job.id }); |
| 1326 | return { jobId: job.id, token, runId: job.run_id, name: job.name }; |
| 1327 | } |
| 1328 | |
| 1329 | // ------------------------------------------------------------ registered self-hosted runners |
| 1330 | |
| 1331 | /** |
| 1332 | * GitHub's runner registration token: a repository admin's credential, good for an hour, that a |
| 1333 | * machine trades for its own runner token. So the machine never holds a person's token, and the |
| 1334 | * jobs it runs (as the same OS user) can't read one. |
| 1335 | */ |
| 1336 | async runnerRegistrationToken(caller: Caller): Promise<{ token: string; expires_at: string }> { |
| 1337 | if (caller.kind !== "user" || caller.permissions || !atLeast(caller.role, "admin")) throw new HttpError(403, "only repository admins can add self-hosted runners"); |
| 1338 | const token = `drg_${randomToken(32)}`; |
| 1339 | const expires = new Date(Date.now() + REGISTRATION_TTL_MS).toISOString(); |
| 1340 | const hash = await sha256(token); |
| 1341 | this.sql.exec("DELETE FROM runner_registrations WHERE expires_at < ?", now()); |
| 1342 | this.sql.exec("INSERT INTO runner_registrations (token_hash, created_by, expires_at) VALUES (?, ?, ?)", hash, caller.login, expires); |
| 1343 | return { token, expires_at: expires }; |
| 1344 | } |
| 1345 | |
| 1346 | /** Register a runner with a registration token; returns its own token, shown once. */ |
| 1347 | async registerRunner(registration: string, name: string, labels: string[], ephemeral: boolean): Promise<{ id: number; name: string; labels: string[]; ephemeral: boolean; token: string; registeredBy: string }> { |
| 1348 | const hash = await sha256(registration); |
| 1349 | const reg = this.sql.exec<{ created_by: string }>("SELECT created_by FROM runner_registrations WHERE token_hash = ? AND expires_at > ?", hash, now()).toArray()[0]; |
| 1350 | if (!reg) throw new HttpError(401, "invalid or expired registration token"); |
| 1351 | const clean = [...new Set(labels.map((l) => String(l).trim().toLowerCase()).filter((l) => l && l !== "self-hosted"))]; |
| 1352 | if (clean.length > 20) throw new HttpError(422, "a runner can offer at most 20 labels"); |
| 1353 | const bad = clean.find((l) => !/^[a-z0-9][a-z0-9._-]{0,63}$/.test(l)); |
| 1354 | if (bad) throw new HttpError(422, `invalid label ${JSON.stringify(bad.slice(0, 70))}: use letters, digits, '.', '_' and '-' (up to 64)`); |
| 1355 | const runnerName = name.trim().slice(0, 64) || "runner"; |
| 1356 | const count = this.sql.exec<{ n: number }>("SELECT count(*) AS n FROM runners").one().n; |
| 1357 | if (count >= MAX_RUNNERS) throw new HttpError(429, `this repository has ${count} self-hosted runners, the most allowed; remove one first`); |
| 1358 | const id = this.sql.exec<{ id: number }>("INSERT INTO runners (name, labels, ephemeral, created_by, created_at) VALUES (?, ?, ?, ?, ?) RETURNING id", runnerName, JSON.stringify(clean), ephemeral ? 1 : 0, reg.created_by, now()).one().id; |
| 1359 | const token = `drn_${this.repo.meta.repoId}_${id}_${randomToken(32)}`; |
| 1360 | this.sql.exec("UPDATE runners SET token_hash = ? WHERE id = ?", await sha256(token), id); |
| 1361 | return { id, name: runnerName, labels: clean, ephemeral, token, registeredBy: reg.created_by }; |
| 1362 | } |
| 1363 | |
| 1364 | /** A registered runner asks for work with its own token. An ephemeral runner's token ends with the job it takes. */ |
| 1365 | async claimJobAsRunner(token: string): Promise<{ jobId: string; token: string; runId: string; name: string } | null> { |
| 1366 | const id = Number(token.split("_")[2]); |
| 1367 | const r = Number.isSafeInteger(id) ? this.sql.exec<RunnerRow>("SELECT * FROM runners WHERE id = ?", id).toArray()[0] : undefined; |
| 1368 | if (!r || !r.token_hash || r.token_hash !== (await sha256(token))) throw new HttpError(401, "this runner was removed, or its token is invalid; register it again"); |
| 1369 | this.sql.exec("UPDATE runners SET last_seen_at = ? WHERE id = ?", now(), r.id); |
| 1370 | const job = await this.assignJob(r.name, JSON.parse(r.labels) as string[], r.id); |
| 1371 | if (job && r.ephemeral) this.sql.exec("UPDATE runners SET token_hash = NULL WHERE id = ?", r.id); |
| 1372 | return job; |
| 1373 | } |
| 1374 | |
| 1375 | runners(caller: Caller): RunnerInfo[] { |
| 1376 | if (!atLeast(caller.role, "admin") || caller.permissions) throw new HttpError(403, "only repository admins can list self-hosted runners"); |
| 1377 | const busy = new Set(this.sql.exec<{ runner_id: number }>("SELECT DISTINCT runner_id FROM jobs WHERE runner_id IS NOT NULL AND status != 'completed'").toArray().map((x) => x.runner_id)); |
| 1378 | return this.sql.exec<RunnerRow>("SELECT * FROM runners ORDER BY id").toArray().map((r) => ({ |
| 1379 | id: r.id, |
| 1380 | name: r.name, |
| 1381 | os: "unknown", |
| 1382 | status: r.last_seen_at && Date.now() - Date.parse(r.last_seen_at) < RUNNER_ONLINE_MS ? "online" : "offline", |
| 1383 | busy: busy.has(r.id), |
| 1384 | ephemeral: !!r.ephemeral, |
| 1385 | labels: ["self-hosted", ...(JSON.parse(r.labels) as string[])].map((name, i) => ({ id: i + 1, name, type: i === 0 ? "read-only" : "custom" })), |
| 1386 | })); |
| 1387 | } |
| 1388 | |
| 1389 | /** Remove a runner: its token stops working at once. A job it is running carries on with its own job token. */ |
| 1390 | deleteRunner(id: number, caller: Caller): void { |
| 1391 | if (caller.kind !== "user" || caller.permissions || !atLeast(caller.role, "admin")) throw new HttpError(403, "only repository admins can remove self-hosted runners"); |
| 1392 | if (!this.sql.exec("DELETE FROM runners WHERE id = ? RETURNING id", id).toArray().length) throw new HttpError(404, "runner not found"); |
| 1393 | } |
| 1394 | |
| 1395 | private async stopContainer(j: JobRow): Promise<void> { |
| 1396 | if (!j.container || j.container.startsWith("self-hosted:")) return; |
| 1397 | try { |
| 1398 | await this.env.RUNNER.get(this.env.RUNNER.idFromName(j.container)).stopJob(); |
| 1399 | } catch (e) { |
| 1400 | console.warn("stop container failed", e); |
| 1401 | } |
| 1402 | } |
| 1403 | |
| 1404 | async finishRun(runId: string): Promise<RunInfo> { |
| 1405 | const run = this.runRow(runId); |
| 1406 | const jobs = this.sql.exec<JobRow>("SELECT * FROM jobs WHERE run_id = ?", runId).toArray(); |
| 1407 | let conclusion: RunRow["conclusion"] = run.conclusion === "cancelled" ? "cancelled" : "success"; |
| 1408 | if (conclusion !== "cancelled") { |
| 1409 | // A job with continue-on-error may fail without failing the run. |
| 1410 | if (jobs.some((j) => (j.conclusion === "failure" || j.conclusion === "timed_out") && !j.continue_on_error)) conclusion = "failure"; |
| 1411 | else if (jobs.some((j) => j.conclusion === "cancelled")) conclusion = "cancelled"; |
| 1412 | // As on GitHub, a run whose jobs were all skipped is skipped, not a success, so a |
| 1413 | // workflow_run follower that checks for success (a live test after a deploy) doesn't run. |
| 1414 | else if (jobs.length && jobs.every((j) => j.conclusion === "skipped")) conclusion = "skipped"; |
| 1415 | } |
| 1416 | const t = now(); |
| 1417 | this.sql.exec("UPDATE runs SET status = 'completed', conclusion = ?, updated_at = ? WHERE id = ?", conclusion, t, runId); |
| 1418 | const m = this.repo.meta; |
| 1419 | this.repo.emit({ type: "workflow_run", action: "completed", repoId: m.repoId, owner: m.owner, repo: m.name, runId, conclusion: conclusion ?? undefined, actor: JSON.parse(run.actor), at: t }); |
| 1420 | this.repo.broadcast(`run:${runId}`, { action: "completed", conclusion }); |
| 1421 | this.repo.broadcast("runs", { action: "completed", runId }); |
| 1422 | await this.onWorkflowRun(this.runRow(runId), "completed"); |
| 1423 | return toRun(this.runRow(runId)); |
| 1424 | } |
| 1425 | |
| 1426 | /** |
| 1427 | * Starts the default branch's `on: workflow_run` workflows that follow `run`. Like |
| 1428 | * GitHub, they run on the default branch, must name the workflows they follow, and |
| 1429 | * chains stop after three levels (A -> B -> C -> D; D's runs trigger nothing). |
| 1430 | */ |
| 1431 | private async onWorkflowRun(run: RunRow, action: "requested" | "completed"): Promise<void> { |
| 1432 | if (this.chainDepth(run) >= 3) return; |
| 1433 | try { |
| 1434 | const sha = this.repo.getRef(this.repo.headRef); |
| 1435 | if (!sha) return; |
| 1436 | const followers = (await this.loadWorkflows(sha)).filter((w) => w.workflow?.on.workflow_run?.workflows?.length); |
| 1437 | if (!followers.length) return; |
| 1438 | const m = this.repo.meta; |
| 1439 | const payload = JSON.parse(run.event_payload) as { pull_request?: { head?: { ref?: string } } }; |
| 1440 | const branch = payload.pull_request?.head?.ref ?? run.ref.replace(/^refs\/(heads|tags)\//, ""); |
| 1441 | const actor = JSON.parse(run.actor) as Actor; |
| 1442 | const workflowRun = { |
| 1443 | id: run.run_number, |
| 1444 | name: run.workflow_name, |
| 1445 | display_title: run.display_title, |
| 1446 | path: run.workflow_path, |
| 1447 | run_number: run.run_number, |
| 1448 | run_attempt: run.attempt, |
| 1449 | event: run.event, |
| 1450 | status: run.status, |
| 1451 | conclusion: run.conclusion, |
| 1452 | head_branch: branch, |
| 1453 | head_sha: run.head_sha ?? run.sha, |
| 1454 | actor: { login: actor.login }, |
| 1455 | triggering_actor: { login: actor.login }, |
| 1456 | pull_requests: run.pr_number ? [{ number: run.pr_number }] : [], |
| 1457 | created_at: run.created_at, |
| 1458 | updated_at: run.updated_at, |
| 1459 | html_url: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}/actions/runs/${run.id}`, |
| 1460 | }; |
| 1461 | for (const w of followers) { |
| 1462 | if (!matchesTrigger(w.workflow!, { name: "workflow_run", action, workflow: run.workflow_name, branch })) continue; |
| 1463 | await this.createRun(w.workflow!, { |
| 1464 | event: "workflow_run", |
| 1465 | ref: this.repo.headRef, |
| 1466 | sha, |
| 1467 | payload: { action, workflow_run: workflowRun, workflow: { name: run.workflow_name, path: run.workflow_path }, repository: await this.repositoryPayload(), sender: { login: actor.login } }, |
| 1468 | caller: actor, |
| 1469 | title: w.workflow!.name ?? w.path, |
| 1470 | // A follower acts on what the run it follows produced (artifacts, outputs), so it stays an agent's. |
| 1471 | agentOrigin: !!run.agent_origin, |
| 1472 | }); |
| 1473 | } |
| 1474 | } catch (e) { |
| 1475 | console.warn("workflow_run trigger failed", e); |
| 1476 | } |
| 1477 | } |
| 1478 | |
| 1479 | /** How many workflow_run links led to `run` (0 for a run started by any other event). */ |
| 1480 | private chainDepth(run: RunRow): number { |
| 1481 | let depth = 0; |
| 1482 | for (let r: RunRow | undefined = run; r?.event === "workflow_run" && depth < 3; depth++) { |
| 1483 | const parent: string | number | undefined = (JSON.parse(r.event_payload) as { workflow_run?: { id?: string | number } }).workflow_run?.id; |
| 1484 | r = parent !== undefined ? this.sql.exec<RunRow>("SELECT * FROM runs WHERE id = ? OR run_number = ? ORDER BY attempt DESC LIMIT 1", String(parent), Number(parent) || -1).toArray()[0] : undefined; |
| 1485 | } |
| 1486 | return depth; |
| 1487 | } |
| 1488 | |
| 1489 | async plannedError(runId: string, message: string): Promise<void> { |
| 1490 | const t = now(); |
| 1491 | this.sql.exec("INSERT INTO jobs (id, run_id, job_key, name, status, conclusion, started_at, completed_at, failure) VALUES (?, ?, 'setup', 'Set up workflow', 'completed', 'failure', ?, ?, ?)", newId(), runId, t, t, JSON.stringify({ message })); |
| 1492 | } |
| 1493 | |
| 1494 | // ---------------------------------------------------------------- cancellation and re-runs |
| 1495 | |
| 1496 | private async cancelInternal(runId: string, reason: string): Promise<void> { |
| 1497 | const run = this.runRow(runId); |
| 1498 | if (run.status === "completed") return; |
| 1499 | this.sql.exec("UPDATE runs SET conclusion = 'cancelled', updated_at = ? WHERE id = ?", now(), run.id); |
| 1500 | for (const j of this.sql.exec<JobRow>("SELECT * FROM jobs WHERE run_id = ? AND status != 'completed'", run.id).toArray()) { |
| 1501 | await this.stopContainer(j); |
| 1502 | await this.completeJob(j.id, { conclusion: "cancelled", outputs: {}, failure: { message: reason } }); |
| 1503 | } |
| 1504 | } |
| 1505 | |
| 1506 | async cancel(runId: string, caller: Caller): Promise<void> { |
| 1507 | if (!atLeast(caller.role, "write")) throw new HttpError(403, "permission denied"); |
| 1508 | await this.cancelInternal(runId, `Cancelled by ${caller.login}`); |
| 1509 | } |
| 1510 | |
| 1511 | async rerun(runId: string, caller: Caller): Promise<RunInfo> { |
| 1512 | if (!atLeast(caller.role, "write")) throw new HttpError(403, "permission denied"); |
| 1513 | if (caller.id.startsWith("job:")) throw new HttpError(403, "a workflow's GITHUB_TOKEN cannot re-run workflows"); |
| 1514 | const old = this.runRow(runId); |
| 1515 | const wf = (await this.loadWorkflows(old.sha)).find((w) => w.path === old.workflow_path)?.workflow; |
| 1516 | if (!wf) throw new HttpError(422, "workflow file no longer valid at this commit"); |
| 1517 | const run = await this.createRun(wf, { |
| 1518 | event: old.event, |
| 1519 | ref: old.ref, |
| 1520 | sha: old.sha, |
| 1521 | headSha: old.head_sha ?? undefined, |
| 1522 | payload: JSON.parse(old.event_payload), |
| 1523 | caller, |
| 1524 | prNumber: old.pr_number ?? undefined, |
| 1525 | inputs: JSON.parse(old.inputs), |
| 1526 | title: old.display_title, |
| 1527 | // Re-running code an agent wrote keeps it acting as the agent, whoever re-runs it. |
| 1528 | agentOrigin: !!old.agent_origin, |
| 1529 | }); |
| 1530 | // Whoever started an earlier attempt still can't approve this one where an environment prevents self-review. |
| 1531 | const prior = [...new Set([...((old.prior_actors ? JSON.parse(old.prior_actors) : []) as string[]), (JSON.parse(old.actor) as Actor).id])]; |
| 1532 | this.sql.exec("UPDATE runs SET attempt = ?, run_number = ?, prior_actors = ? WHERE id = ?", old.attempt + 1, old.run_number, JSON.stringify(prior), run!.id); |
| 1533 | return toRun(this.runRow(run!.id)); |
| 1534 | } |
| 1535 | |
| 1536 | // ---------------------------------------------------------------- runner protocol |
| 1537 | |
| 1538 | /** Validate a job or sandbox token. Returns what it grants. */ |
| 1539 | async runnerAuth( |
| 1540 | token: string, |
| 1541 | ): Promise<{ kind: "job"; jobId: string; runId: string; role: Caller["role"]; permissions: JobPermissions; actorKind: Actor["kind"] } | { kind: "sandbox"; sandboxId: string; caller: Caller; taskPerson?: string } | null> { |
| 1542 | const parts = token.split("_"); |
| 1543 | if (parts.length < 4) return null; |
| 1544 | const hash = await sha256(token); |
| 1545 | if (parts[0] === "djt") { |
| 1546 | const j = this.sql.exec<JobRow>("SELECT * FROM jobs WHERE id = ?", parts[2]!).toArray()[0]; |
| 1547 | if (!j || j.token_hash !== hash || j.status === "completed") return null; |
| 1548 | const grant = this.repo.runtime.grantFor(j.id); |
| 1549 | // Code an agent wrote, pushed or opened a pull request with runs as that agent (repo/token-policy.ts). |
| 1550 | const run = this.sql.exec<{ agent_origin: number }>("SELECT agent_origin FROM runs WHERE id = ?", j.run_id).toArray()[0]; |
| 1551 | const actorKind: Actor["kind"] = run?.agent_origin ? "agent" : "system"; |
| 1552 | return { kind: "job", jobId: j.id, runId: j.run_id, role: grant.role, permissions: grant.permissions, actorKind }; |
| 1553 | } |
| 1554 | if (parts[0] === "dst") { |
| 1555 | const s = this.sql.exec<{ id: string; secret: string; created_by: string; status: string; created_at: string; task_person: string | null }>("SELECT * FROM sandboxes WHERE id = ?", parts[2]!).toArray()[0]; |
| 1556 | if (!s || s.secret !== hash || s.status !== "running") return null; |
| 1557 | // A sandbox's token lasts a day at most, even if nobody closes the sandbox. |
| 1558 | if (Date.now() - Date.parse(s.created_at) > SANDBOX_TOKEN_TTL_MS) return null; |
| 1559 | return { kind: "sandbox", sandboxId: s.id, caller: JSON.parse(s.created_by) as Caller, ...(s.task_person ? { taskPerson: s.task_person } : {}) }; |
| 1560 | } |
| 1561 | return null; |
| 1562 | } |
| 1563 | |
| 1564 | private async authJob(jobId: string, token: string): Promise<JobRow> { |
| 1565 | const auth = await this.runnerAuth(token); |
| 1566 | if (!auth || auth.kind !== "job" || auth.jobId !== jobId) throw new HttpError(401, "invalid job token"); |
| 1567 | return this.jobRow(jobId); |
| 1568 | } |
| 1569 | |
| 1570 | /** Claims for an OIDC id token; side-effect free (unlike runnerSpec). */ |
| 1571 | async oidcClaims(jobId: string, token: string): Promise<{ allowed: boolean; github: Record<string, string> }> { |
| 1572 | const job = await this.authJob(jobId, token); |
| 1573 | if (job.status !== "in_progress") throw new HttpError(409, "job is not running"); |
| 1574 | const run = this.runRow(job.run_id); |
| 1575 | const exp = await this.expanded(run); |
| 1576 | const def: Job = exp.workflow.jobs[job.job_key]!; |
| 1577 | const wf = exp.workflowOf.get(job.job_key)!; |
| 1578 | const allowed = this.repo.runtime.grantFor(jobId).permissions["id-token"] === "write"; |
| 1579 | const ctx = this.githubContext(run, localKey(job.job_key, exp.parentOf.get(job.job_key) ?? null), token) as Record<string, Json>; |
| 1580 | const github: Record<string, string> = {}; |
| 1581 | for (const [k, v] of Object.entries(ctx)) if (typeof v === "string" || typeof v === "number") github[k] = String(v); |
| 1582 | // Ids don't change when a name is reused, so a cloud trust policy can pin them; the environment is the one |
| 1583 | // whose protection rules this job passed (runnerSpec records its deployment). |
| 1584 | github.repository_owner_id = String(numericId(this.repo.meta.ownerId)); |
| 1585 | const actor = JSON.parse(run.actor) as Actor; |
| 1586 | if (actor.id) github.actor_id = String(numericId(actor.id)); |
| 1587 | const env = this.sql.exec<{ environment: string }>("SELECT environment FROM deployments WHERE job_id = ?", jobId).toArray()[0]; |
| 1588 | if (env) github.environment = env.environment; |
| 1589 | return { allowed, github }; |
| 1590 | } |
| 1591 | |
| 1592 | /** |
| 1593 | * What a job token may do with container packages (the `packages` permission). |
| 1594 | * A workflow without a permissions block gets the repository's default: read in |
| 1595 | * repositories with read-only tokens (the default for new ones), write in those that |
| 1596 | * keep GitHub's old permissive default. A permissions map that leaves `packages` out gets none. |
| 1597 | */ |
| 1598 | async packagesPermission(token: string): Promise<"none" | "read" | "write"> { |
| 1599 | const auth = await this.runnerAuth(token); |
| 1600 | return auth?.kind === "job" ? auth.permissions.packages : "none"; |
| 1601 | } |
| 1602 | |
| 1603 | /** |
| 1604 | * A pull_request run for a pull request from another repository (a fork): its jobs |
| 1605 | * never get write or secrets. Decided from the pull request's row, not the event payload. |
| 1606 | */ |
| 1607 | private untrusted(run: RunRow): boolean { |
| 1608 | return run.event === "pull_request" && this.repo.policy.isForkPull(run.pr_number); |
| 1609 | } |
| 1610 | |
| 1611 | async runnerSpec(jobId: string, token: string): Promise<JobSpec> { |
| 1612 | const job = await this.authJob(jobId, token); |
| 1613 | const run = this.runRow(job.run_id); |
| 1614 | const exp = await this.expanded(run); |
| 1615 | const def: Job = exp.workflow.jobs[job.job_key]!; |
| 1616 | const wf = exp.workflowOf.get(job.job_key)!; |
| 1617 | const parent = exp.parentOf.get(job.job_key) ?? null; |
| 1618 | const short = localKey(job.job_key, parent); |
| 1619 | let scope: { inputs: Record<string, unknown>; secrets: Record<string, string> }; |
| 1620 | try { |
| 1621 | // A fork's code never sees the repository's secrets, as on GitHub, and an agent's only where an admin allows it. |
| 1622 | scope = await this.callContext(run, exp, parent, !this.untrusted(run) && (!run.agent_origin || this.repo.policy.settings().agentRunSecrets)); |
| 1623 | } catch (e) { |
| 1624 | await this.completeJob(jobId, { conclusion: "failure", outputs: {}, failure: { message: (e as Error).message } }); |
| 1625 | throw new HttpError(409, (e as Error).message); |
| 1626 | } |
| 1627 | const m = this.repo.meta; |
| 1628 | // Deployment environment (`environment:`): its branch policy, secrets and variables, and a deployment record. |
| 1629 | let environment: { secrets: Record<string, string>; vars: Record<string, string> } = { secrets: {}, vars: {} }; |
| 1630 | if (def.environment) { |
| 1631 | try { |
| 1632 | const ctx = { github: this.githubContext(run, short, ""), inputs: scope.inputs, vars: this.variables(), matrix: JSON.parse(job.matrix), strategy: JSON.parse(job.strategy), needs: await this.needsResults(run, exp, exp.needs.get(job.job_key) ?? [], parent) }; |
| 1633 | // The name evaluated at launch, whose protection rules the job passed (older jobs evaluate it here). |
| 1634 | const name = job.environment ?? interpolate(def.environment.name, ctx); |
| 1635 | if (this.repo.deploys.protection(name) && job.gate_state !== "released") throw new Error(`The environment ${name} requires approval this job didn't get. Re-run the job to ask for it.`); |
| 1636 | const url = def.environment.url && !def.environment.url.includes("steps.") ? interpolate(def.environment.url, ctx) : undefined; |
| 1637 | const logUrl = `${this.env.PUBLIC_URL}/${m.owner}/${m.name}/actions/runs/${run.id}/job/${jobId}`; |
| 1638 | // Code an agent wrote deploys as that agent (environment rules hold it to the agent's limits). |
| 1639 | const actor = JSON.parse(run.actor) as Actor; |
| 1640 | environment = await this.repo.deploys.jobStarted({ id: jobId, ref: run.ref, sha: run.sha, logUrl, actor: run.agent_origin ? { ...actor, kind: "agent" } : actor }, { name, url }); |
| 1641 | } catch (e) { |
| 1642 | await this.completeJob(jobId, { conclusion: "failure", outputs: {}, failure: { message: (e as Error).message } }); |
| 1643 | throw new HttpError(409, (e as Error).message); |
| 1644 | } |
| 1645 | } |
| 1646 | const t = now(); |
| 1647 | this.sql.exec("UPDATE jobs SET status = 'in_progress', started_at = ? WHERE id = ?", t, jobId); |
| 1648 | this.repo.upsertCheck({ sha: run.head_sha ?? run.sha, name: this.checkName(run, job), status: "in_progress", runId: run.id, jobId, detailsUrl: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}/actions/runs/${run.id}/job/${jobId}` }); |
| 1649 | this.repo.broadcast(`run:${run.id}`, { action: "job", jobId }); |
| 1650 | // Only the secrets the job's expressions name reach the runner, as on GitHub (plus the runner's own debug switches). |
| 1651 | const used = referencedSecrets(def, wf.env); |
| 1652 | if (used !== "all") for (const name of RUNNER_SECRETS) used.add(name); |
| 1653 | const secrets: Record<string, string> = { ...secretsFor({ ...scope.secrets, ...(this.untrusted(run) ? {} : environment.secrets) }, used), GITHUB_TOKEN: token, DIFFINERY_TOKEN: token }; |
| 1654 | // The job token can do what the effective `permissions:` allow (job level wins, like GitHub). |
| 1655 | const permissions = permissionsForJob(exp, job.job_key, { untrusted: this.untrusted(run), repoDefault: this.repo.policy.settings().defaultPermissions }); |
| 1656 | this.repo.runtime.grant(jobId, permissions); |
| 1657 | const idToken = permissions["id-token"] === "write"; |
| 1658 | return { |
| 1659 | runId: run.id, |
| 1660 | jobId, |
| 1661 | jobKey: short, |
| 1662 | name: job.name, |
| 1663 | repo: { owner: m.owner, name: m.name, cloneUrl: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}.git`, defaultBranch: m.defaultBranch }, |
| 1664 | sha: run.sha, |
| 1665 | ref: run.ref, |
| 1666 | workflowPath: run.workflow_path, |
| 1667 | github: this.githubContext(run, short, token), |
| 1668 | env: { ...wf.env, ...def.env }, |
| 1669 | defaults: { run: { ...wf.defaults.run, ...def.defaults.run } }, |
| 1670 | matrix: JSON.parse(job.matrix), |
| 1671 | strategy: JSON.parse(job.strategy), |
| 1672 | needs: await this.needsResults(run, exp, exp.needs.get(job.job_key) ?? [], parent), |
| 1673 | inputs: scope.inputs, |
| 1674 | vars: { ...this.variables(), ...environment.vars }, |
| 1675 | secrets, |
| 1676 | outputs: def.outputs, |
| 1677 | steps: def.steps.map((s) => ({ ...s, "timeout-minutes": s["timeout-minutes"] === undefined ? undefined : Number(s["timeout-minutes"]) })), |
| 1678 | container: def.container, |
| 1679 | services: def.services, |
| 1680 | timeoutMinutes: job.timeout_minutes, |
| 1681 | continueOnError: !!job.continue_on_error, |
| 1682 | idToken: !!idToken, |
| 1683 | apiUrl: this.env.PUBLIC_URL, |
| 1684 | token, |
| 1685 | runtime: await this.runtimeServices(run, job), |
| 1686 | }; |
| 1687 | } |
| 1688 | |
| 1689 | /** ACTIONS_RUNTIME_TOKEN and the service URLs; the token expires when the job would time out. */ |
| 1690 | private async runtimeServices(run: RunRow, job: JobRow): Promise<JobSpec["runtime"]> { |
| 1691 | if (!this.env.MASTER_KEY) return undefined; |
| 1692 | const origin = new URL(this.env.PUBLIC_URL).origin; |
| 1693 | const exp = Math.floor(Date.now() / 1000) + (job.timeout_minutes + 30) * 60; |
| 1694 | const { scopes } = this.repo.runtime.job(job.id); |
| 1695 | const token = await signRuntimeToken(this.env.MASTER_KEY, origin, { repo: this.repo.meta.repoId, run: run.id, job: job.id, exp }, scopes); |
| 1696 | return { token, resultsUrl: `${origin}/`, cacheUrl: `${origin}/_runtime/`, runtimeUrl: `${origin}/_runtime/` }; |
| 1697 | } |
| 1698 | |
| 1699 | async runnerLogs(jobId: string, token: string, lines: LogLine[]): Promise<void> { |
| 1700 | const job = await this.authJob(jobId, token); |
| 1701 | let chars = job.log_chars ?? 0; |
| 1702 | if (chars > MAX_JOB_LOG) return; |
| 1703 | let seq = this.sql.exec<{ n: number | null }>("SELECT max(seq) AS n FROM logs WHERE job_id = ?", jobId).one().n ?? -1; |
| 1704 | const out: Array<LogLine & { seq: number }> = []; |
| 1705 | for (const l of lines.slice(0, 5000)) { |
| 1706 | seq++; |
| 1707 | const t = String(l.t ?? "").slice(0, 40); |
| 1708 | const step = Number.isInteger(l.step) ? l.step : 0; |
| 1709 | let text = String(l.text ?? ""); |
| 1710 | if (text.length > 16384) text = `${text.slice(0, 16384)}…`; |
| 1711 | // Past the cap, one last line says so and the rest is dropped: the whole log is read into |
| 1712 | // memory when the job completes, to archive it. |
| 1713 | if (chars + text.length > MAX_JOB_LOG) { |
| 1714 | text = `The log is cut off here: a job's log is limited to ${MAX_JOB_LOG / 1048576} MiB of text.`; |
| 1715 | chars = MAX_JOB_LOG + 1; |
| 1716 | } else chars += text.length; |
| 1717 | this.sql.exec("INSERT INTO logs (job_id, seq, t, step, text) VALUES (?, ?, ?, ?, ?)", jobId, seq, t, step, text); |
| 1718 | out.push({ t, step, text, seq }); |
| 1719 | if (chars > MAX_JOB_LOG) break; |
| 1720 | } |
| 1721 | this.sql.exec("UPDATE jobs SET log_chars = ? WHERE id = ?", chars, jobId); |
| 1722 | this.repo.broadcast(`job:${jobId}`, { action: "logs", lines: out }); |
| 1723 | } |
| 1724 | |
| 1725 | async runnerStep(jobId: string, token: string, index: number, report: StepReport): Promise<void> { |
| 1726 | const job = await this.authJob(jobId, token); |
| 1727 | const steps = JSON.parse(job.steps) as Array<StepReport & { index: number }>; |
| 1728 | const existing = steps.findIndex((s) => s.index === index); |
| 1729 | const next = { ...(existing >= 0 ? steps[existing] : {}), ...report, index }; |
| 1730 | if (existing >= 0) steps[existing] = next; |
| 1731 | else steps.push(next); |
| 1732 | steps.sort((a, b) => a.index - b.index); |
| 1733 | this.sql.exec("UPDATE jobs SET steps = ? WHERE id = ?", JSON.stringify(steps), jobId); |
| 1734 | this.repo.broadcast(`job:${jobId}`, { action: "step", step: next }); |
| 1735 | this.repo.broadcast(`run:${job.run_id}`, { action: "job", jobId }); |
| 1736 | } |
| 1737 | |
| 1738 | async runnerComplete(jobId: string, token: string, completion: JobCompletion): Promise<void> { |
| 1739 | await this.authJob(jobId, token); |
| 1740 | await this.completeJob(jobId, completion); |
| 1741 | } |
| 1742 | |
| 1743 | private async completeJob(jobId: string, completion: JobCompletion, override?: "timed_out"): Promise<void> { |
| 1744 | const job = this.jobRow(jobId); |
| 1745 | if (job.status === "completed") return; |
| 1746 | const gated = job.status === "approval"; |
| 1747 | const run = this.runRow(job.run_id); |
| 1748 | const t = now(); |
| 1749 | const conclusion = override ?? completion.conclusion; |
| 1750 | this.sql.exec( |
| 1751 | "UPDATE jobs SET status = 'completed', conclusion = ?, completed_at = ?, outputs = ?, failure = ?, summary = ?, token_hash = NULL, started_at = COALESCE(started_at, ?) WHERE id = ?", |
| 1752 | conclusion, |
| 1753 | t, |
| 1754 | JSON.stringify(completion.outputs ?? {}), |
| 1755 | completion.failure ? JSON.stringify(completion.failure) : null, |
| 1756 | completion.summary ?? null, |
| 1757 | t, |
| 1758 | jobId, |
| 1759 | ); |
| 1760 | this.repo.deploys.jobCompleted(jobId, conclusion); |
| 1761 | const m = this.repo.meta; |
| 1762 | this.repo.upsertCheck({ |
| 1763 | sha: run.head_sha ?? run.sha, |
| 1764 | name: this.checkName(run, job), |
| 1765 | status: "completed", |
| 1766 | conclusion: conclusion === "timed_out" ? "timed_out" : conclusion, |
| 1767 | runId: run.id, |
| 1768 | jobId, |
| 1769 | detailsUrl: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}/actions/runs/${run.id}/job/${jobId}`, |
| 1770 | output: completion.failure ? { title: "Failed", summary: completion.failure.message } : { title: conclusion }, |
| 1771 | }); |
| 1772 | // Archive logs to R2 and free the Durable Object's storage. |
| 1773 | const lines = this.sql.exec<LogLine & { seq: number }>("SELECT seq, t, step, text FROM logs WHERE job_id = ? ORDER BY seq", jobId).toArray(); |
| 1774 | if (lines.length) { |
| 1775 | await this.env.BUCKET.put(this.logKey(jobId), JSON.stringify(lines), { httpMetadata: { contentType: "application/json" } }); |
| 1776 | this.sql.exec("DELETE FROM logs WHERE job_id = ?", jobId); |
| 1777 | } |
| 1778 | // Uploads the job left unfinished stop counting against the budgets, and their bytes go. |
| 1779 | await this.repo.runtime.sweepJob(jobId).catch((e) => console.error("runtime sweep failed", e)); |
| 1780 | this.repo.broadcast(`job:${jobId}`, { action: "completed", conclusion }); |
| 1781 | this.repo.broadcast(`run:${run.id}`, { action: "job", jobId }); |
| 1782 | |
| 1783 | // fail-fast: cancel unfinished siblings of a failed matrix job (unless the failed leg may continue on error). |
| 1784 | const siblings = this.sql.exec<JobRow>("SELECT * FROM jobs WHERE run_id = ? AND job_key = ? AND id != ? AND status != 'completed'", run.id, job.job_key, jobId).toArray(); |
| 1785 | const strategy = JSON.parse(job.strategy || "{}") as { "fail-fast"?: boolean }; |
| 1786 | if ((conclusion === "failure" || conclusion === "timed_out") && !job.continue_on_error && strategy["fail-fast"] !== false && siblings.length) { |
| 1787 | for (const s of siblings) { |
| 1788 | await this.stopContainer(s); |
| 1789 | await this.completeJob(s.id, { conclusion: "cancelled", outputs: {}, failure: { message: `Cancelled because ${job.name} failed (fail-fast).` } }); |
| 1790 | } |
| 1791 | } else { |
| 1792 | const queued = siblings.find((s) => s.status === "queued"); |
| 1793 | if (queued) await this.launch(queued.id); |
| 1794 | } |
| 1795 | // An ephemeral runner is done once its one job is. |
| 1796 | if (job.runner_id) this.sql.exec("DELETE FROM runners WHERE id = ? AND ephemeral = 1 AND token_hash IS NULL", job.runner_id); |
| 1797 | // A hosted job hands its slot to the oldest job waiting for one (the repository's or the account's). |
| 1798 | if (!job.labels && job.container) { |
| 1799 | await endCompute(this.env, jobId).catch((e: unknown) => console.error("compute usage failed", e)); |
| 1800 | const held = this.sql.exec<{ id: string }>("SELECT id FROM jobs WHERE fleet_held > 0 AND status = 'queued' ORDER BY rowid LIMIT 1").toArray()[0]; |
| 1801 | if (held) await this.launch(held.id); |
| 1802 | } |
| 1803 | // A job that held its concurrency group hands it to the oldest pending job (of any run). |
| 1804 | if (job.concurrency_group && (job.status === "waiting" || job.status === "in_progress")) { |
| 1805 | const next = this.sql.exec<{ id: string }>("SELECT id FROM jobs WHERE concurrency_group = ? AND status = 'pending' ORDER BY rowid LIMIT 1", job.concurrency_group).toArray()[0]; |
| 1806 | if (next) await this.launch(next.id); |
| 1807 | } |
| 1808 | try { |
| 1809 | const instance = await this.env.RUN_WORKFLOW.get(run.id); |
| 1810 | await instance.sendEvent({ type: `job-${jobId}`, payload: { conclusion } }); |
| 1811 | } catch (e) { |
| 1812 | console.error("failed to notify workflow", e); |
| 1813 | } |
| 1814 | // The Workflow waits for a job held for approval on its own event. |
| 1815 | if (gated) { |
| 1816 | this.syncWaiting(run.id); |
| 1817 | await this.wakeGate(run.id, jobId); |
| 1818 | } |
| 1819 | } |
| 1820 | |
| 1821 | // ---------------------------------------------------------------- secrets and variables |
| 1822 | |
| 1823 | private async repoSecrets(): Promise<Record<string, string>> { |
| 1824 | const out: Record<string, string> = {}; |
| 1825 | for (const r of this.sql.exec<{ name: string; ciphertext: string }>("SELECT name, ciphertext FROM secrets").toArray()) { |
| 1826 | out[r.name] = await decryptSecret(this.env.MASTER_KEY, `repo:${this.repo.meta.repoId}`, r.ciphertext); |
| 1827 | } |
| 1828 | return out; |
| 1829 | } |
| 1830 | |
| 1831 | private async orgSecrets(): Promise<Record<string, string>> { |
| 1832 | const m = this.repo.meta; |
| 1833 | if (m.ownerKind !== "org") return {}; |
| 1834 | // Only the secrets whose repository selection includes this repository (visibility all, private or selected). |
| 1835 | const rows = await this.env.DB.prepare( |
| 1836 | `SELECT s.name, s.ciphertext FROM org_secrets s WHERE s.org_id = ?1 AND (s.visibility = 'all' |
| 1837 | OR (s.visibility = 'private' AND EXISTS (SELECT 1 FROM repos r WHERE r.id = ?2 AND r.visibility != 'public')) |
| 1838 | OR (s.visibility = 'selected' AND EXISTS (SELECT 1 FROM org_secret_repos x WHERE x.org_id = s.org_id AND x.name = s.name AND x.repo_id = ?2)))`, |
| 1839 | ) |
| 1840 | .bind(m.ownerId, m.repoId) |
| 1841 | .all<{ name: string; ciphertext: string }>(); |
| 1842 | const out: Record<string, string> = {}; |
| 1843 | for (const r of rows.results) out[r.name] = await decryptSecret(this.env.MASTER_KEY, `org:${m.ownerId}`, r.ciphertext); |
| 1844 | return out; |
| 1845 | } |
| 1846 | |
| 1847 | async setSecret(name: string, value: string): Promise<void> { |
| 1848 | if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || /^GITHUB_/i.test(name)) throw new HttpError(422, "invalid secret name"); |
| 1849 | this.sql.exec("INSERT OR REPLACE INTO secrets (name, ciphertext, updated_at) VALUES (?, ?, ?)", name.toUpperCase(), await encryptSecret(this.env.MASTER_KEY, `repo:${this.repo.meta.repoId}`, value), now()); |
| 1850 | } |
| 1851 | |
| 1852 | deleteSecret(name: string): void { |
| 1853 | this.sql.exec("DELETE FROM secrets WHERE name = ?", name.toUpperCase()); |
| 1854 | } |
| 1855 | |
| 1856 | secretNames(): Array<{ name: string; updatedAt: string }> { |
| 1857 | return this.sql.exec<{ name: string; updated_at: string }>("SELECT name, updated_at FROM secrets ORDER BY name").toArray().map((r) => ({ name: r.name, updatedAt: r.updated_at })); |
| 1858 | } |
| 1859 | |
| 1860 | setVariable(name: string, value: string | null): void { |
| 1861 | if (value === null) this.sql.exec("DELETE FROM variables WHERE name = ?", name.toUpperCase()); |
| 1862 | else this.sql.exec("INSERT OR REPLACE INTO variables (name, value, updated_at) VALUES (?, ?, ?)", name.toUpperCase(), value, now()); |
| 1863 | } |
| 1864 | |
| 1865 | /** Variables for `vars.*`: the organization's, overridden by the repository's. `withOrg = false` lists only the repository's own. */ |
| 1866 | variables(withOrg = true): Record<string, string> { |
| 1867 | const own = Object.fromEntries(this.sql.exec<{ name: string; value: string }>("SELECT name, value FROM variables").toArray().map((r) => [r.name, r.value])); |
| 1868 | if (!withOrg) return own; |
| 1869 | const org = Object.fromEntries(this.sql.exec<{ name: string; value: string }>("SELECT name, value FROM org_variables").toArray().map((r) => [r.name, r.value])); |
| 1870 | return { ...org, ...own }; |
| 1871 | } |
| 1872 | |
| 1873 | // ---------------------------------------------------------------- cache and artifacts |
| 1874 | |
| 1875 | /** |
| 1876 | * An older runner's cache (the runner API before the toolkit's cache service). |
| 1877 | * Entries are stored as `<ref>\n<key>` and scoped like the cache service's |
| 1878 | * (repo/runtime.ts): a job saves to its own ref and restores from its own ref, |
| 1879 | * then the pull request's base, then the default branch, so one branch or pull |
| 1880 | * request never sees another's caches. Entries saved before scoping (no ref) |
| 1881 | * are never restored, so they go a week after their last use. |
| 1882 | */ |
| 1883 | async cacheLookup(jobId: string, token: string, key: string, restoreKeys: string[]): Promise<{ key: string; stored: string } | null> { |
| 1884 | await this.authJob(jobId, token); |
| 1885 | const { scopes } = this.repo.runtime.job(jobId); |
| 1886 | // An entry whose object is gone is forgotten, and the lookup moves on. |
| 1887 | for (let attempt = 0; attempt < 5; attempt++) { |
| 1888 | const hit = this.legacyCacheMatch(scopes, key, restoreKeys); |
| 1889 | if (!hit) return null; |
| 1890 | if (!(await headFirst(this.env.BUCKET, this.cacheR2Keys(hit)))) { |
| 1891 | this.sql.exec("DELETE FROM caches WHERE key = ?", hit); |
| 1892 | continue; |
| 1893 | } |
| 1894 | this.sql.exec("UPDATE caches SET last_used = ? WHERE key = ?", now(), hit); |
| 1895 | return { key: hit.slice(hit.indexOf("\n") + 1), stored: hit }; |
| 1896 | } |
| 1897 | return null; |
| 1898 | } |
| 1899 | |
| 1900 | /** In each scope in turn: the exact key, then the newest entry for each restore key prefix. */ |
| 1901 | private legacyCacheMatch(scopes: string[], key: string, restoreKeys: string[]): string | null { |
| 1902 | for (const scope of scopes) { |
| 1903 | const exact = this.sql.exec<{ key: string }>("SELECT key FROM caches WHERE key = ?", `${scope}\n${key}`).toArray()[0]; |
| 1904 | if (exact) return exact.key; |
| 1905 | for (const prefix of restoreKeys) { |
| 1906 | const p = `${scope}\n${prefix}`; |
| 1907 | const r = this.sql.exec<{ key: string }>("SELECT key FROM caches WHERE substr(key, 1, ?) = ? ORDER BY created_at DESC LIMIT 1", p.length, p).toArray()[0]; |
| 1908 | if (r) return r.key; |
| 1909 | } |
| 1910 | } |
| 1911 | return null; |
| 1912 | } |
| 1913 | |
| 1914 | /** Where a job saves `key` (its own ref's scope): the stored key and its R2 keys. */ |
| 1915 | async cacheSaveKeys(jobId: string, token: string, key: string): Promise<{ stored: string; r2Keys: string[] }> { |
| 1916 | await this.authJob(jobId, token); |
| 1917 | const stored = `${this.repo.runtime.job(jobId).scopes[0]}\n${key}`; |
| 1918 | return { stored, r2Keys: this.cacheR2Keys(stored) }; |
| 1919 | } |
| 1920 | |
| 1921 | /** The R2 keys of entry `stored`, when the job may read its scope. */ |
| 1922 | async cacheReadKeys(jobId: string, token: string, stored: string): Promise<string[] | null> { |
| 1923 | await this.authJob(jobId, token); |
| 1924 | const nl = stored.indexOf("\n"); |
| 1925 | if (nl < 0 || !this.repo.runtime.job(jobId).scopes.includes(stored.slice(0, nl))) return null; |
| 1926 | return this.cacheR2Keys(stored); |
| 1927 | } |
| 1928 | |
| 1929 | /** Where an older runner's cache entry is stored: the current key, then the old layout's. */ |
| 1930 | cacheR2Keys(key: string): string[] { |
| 1931 | return legacyCacheKeys(this.repo.meta.repoId, key); |
| 1932 | } |
| 1933 | |
| 1934 | /** Records a saved entry; returns the R2 keys of the entries it evicted, for the caller to delete. */ |
| 1935 | async cacheSaved(jobId: string, token: string, key: string, size: number): Promise<string[]> { |
| 1936 | const { stored } = await this.cacheSaveKeys(jobId, token, key); |
| 1937 | this.sql.exec("INSERT OR REPLACE INTO caches (key, size, created_at, last_used) VALUES (?, ?, ?, ?)", stored, size, now(), now()); |
| 1938 | // Like the cache service (repo/runtime.ts): entries unused for a week go, then the |
| 1939 | // least recently used ones above 10 GB per repository. |
| 1940 | const stale = this.sql.exec<{ key: string }>("SELECT key FROM caches WHERE last_used < ?", new Date(Date.now() - CACHE_IDLE_MS).toISOString()).toArray().map((r) => r.key); |
| 1941 | for (const k of stale) this.sql.exec("DELETE FROM caches WHERE key = ?", k); |
| 1942 | const evicted = stale.flatMap((k) => this.cacheR2Keys(k)); |
| 1943 | let total = this.sql.exec<{ n: number | null }>("SELECT sum(size) AS n FROM caches").one().n ?? 0; |
| 1944 | while (total > CACHE_LIMIT_BYTES) { |
| 1945 | const old = this.sql.exec<{ key: string; size: number }>("SELECT key, size FROM caches ORDER BY last_used LIMIT 1").one(); |
| 1946 | this.sql.exec("DELETE FROM caches WHERE key = ?", old.key); |
| 1947 | evicted.push(...this.cacheR2Keys(old.key)); |
| 1948 | total -= old.size; |
| 1949 | } |
| 1950 | return evicted; |
| 1951 | } |
| 1952 | |
| 1953 | /** |
| 1954 | * Where job `jobId` (authenticated by its token) stores artifact `name` of its run, and how many bytes |
| 1955 | * it may store there (what is left of the artifact quota). Refused when the run |
| 1956 | * already has an artifact of that name from the artifact service. |
| 1957 | */ |
| 1958 | async artifactUploadKeys(jobId: string, token: string, name: string): Promise<{ r2Keys: string[]; room: number }> { |
| 1959 | const job = await this.authJob(jobId, token); |
| 1960 | this.repo.runtime.legacyArtifactCheck(job.run_id, name); |
| 1961 | return { r2Keys: this.artifactR2Keys(job.run_id, name), room: await this.repo.runtime.artifactRoom() }; |
| 1962 | } |
| 1963 | |
| 1964 | /** Records an older runner's artifact, stored at its legacy key, with the artifact service's (repo/runtime.ts). */ |
| 1965 | async artifactSaved(jobId: string, token: string, name: string, size: number): Promise<{ id: number }> { |
| 1966 | const job = await this.authJob(jobId, token); |
| 1967 | return { id: this.repo.runtime.legacyArtifactSaved(job.run_id, jobId, name, size).id }; |
| 1968 | } |
| 1969 | |
| 1970 | /** Where job `jobId` (authenticated by its token) reads artifact `name` of its run. */ |
| 1971 | async artifactKeysFor(jobId: string, token: string, name: string): Promise<string[]> { |
| 1972 | const job = await this.authJob(jobId, token); |
| 1973 | return this.artifactR2Keys(job.run_id, name); |
| 1974 | } |
| 1975 | |
| 1976 | /** Where an older runner's artifact is stored: the current key, then the old layout's. */ |
| 1977 | artifactR2Keys(runId: string, name: string): string[] { |
| 1978 | return legacyArtifactKeys(this.repo.meta.repoId, runId, name); |
| 1979 | } |
| 1980 | |
| 1981 | /** |
| 1982 | * A run's artifacts: those uploaded through the artifact service (zip files, or |
| 1983 | * single unarchived files) and older runners' tarballs. |
| 1984 | */ |
| 1985 | artifacts(runId: string): Array<{ name: string; size: number; createdAt: string; r2Keys: string[]; id: number; contentType: string; fileName: string }> { |
| 1986 | runId = this.runRow(runId).id; |
| 1987 | return this.repo.runtime |
| 1988 | .runArtifacts(runId) |
| 1989 | .map((a) => ({ |
| 1990 | name: a.name, |
| 1991 | size: a.size, |
| 1992 | createdAt: a.createdAt, |
| 1993 | r2Keys: artifactObjectKeys(this.repo.meta.repoId, a), |
| 1994 | id: a.id, |
| 1995 | contentType: a.mimeType, |
| 1996 | fileName: artifactFileName(a), |
| 1997 | })) |
| 1998 | .sort((a, b) => a.name.localeCompare(b.name)); |
| 1999 | } |
| 2000 | |
| 2001 | // ---------------------------------------------------------------- sandboxes for agents |
| 2002 | |
| 2003 | /** |
| 2004 | * `task` is set only by agentic/ (never from a request): the sandbox of an agent task, started for |
| 2005 | * the agent `caller` by the person `task.personId`. No public sandbox call reaches it. An agent |
| 2006 | * review's sandbox is `readOnly`: stored with a `read` caller, its git credential can't push. |
| 2007 | */ |
| 2008 | async createSandbox(caller: Caller, ref?: string, task?: { personId: string; readOnly?: boolean }): Promise<{ id: string; ref: string }> { |
| 2009 | if (!atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to create a sandbox"); |
| 2010 | // A review's sandbox acts with read access only: its git credential can clone but never push. |
| 2011 | if (task?.readOnly) caller = { ...caller, role: "read" }; |
| 2012 | if (!task) { |
| 2013 | const since = new Date(Date.now() - SANDBOX_TOKEN_TTL_MS).toISOString(); |
| 2014 | const open = this.sql |
| 2015 | .exec<{ n: number }>("SELECT count(*) AS n FROM sandboxes WHERE kind = 'api' AND status = 'running' AND created_at > ? AND json_extract(created_by, '$.id') = ?", since, caller.id) |
| 2016 | .one().n; |
| 2017 | if (open >= MAX_OPEN_SANDBOXES) throw new HttpError(429, `you have ${open} open sandboxes in this repository, the most allowed; close one first`); |
| 2018 | } |
| 2019 | // The account that owns the repository pays for sandboxes too (lib/quotas.ts). |
| 2020 | const quota = await mayStart(this.env, this.repo.meta.ownerId, "sandbox"); |
| 2021 | if (!quota.ok) throw new HttpError(429, quota.message); |
| 2022 | const sha = await this.repo.resolve(ref ?? "HEAD"); |
| 2023 | if (!sha) throw new HttpError(422, "unknown ref"); |
| 2024 | const id = newId(); |
| 2025 | const m = this.repo.meta; |
| 2026 | const token = `dst_${m.repoId}_${id}_${randomToken(24)}`; |
| 2027 | const secret = randomToken(32); |
| 2028 | this.sql.exec( |
| 2029 | "INSERT INTO sandboxes (id, secret, created_by, ref, created_at, status, kind, task_person) VALUES (?, ?, ?, ?, ?, 'running', ?, ?)", |
| 2030 | id, |
| 2031 | await sha256(token), |
| 2032 | JSON.stringify(caller), |
| 2033 | ref ?? m.defaultBranch, |
| 2034 | now(), |
| 2035 | task ? "task" : "api", |
| 2036 | task?.personId ?? null, |
| 2037 | ); |
| 2038 | await startCompute(this.env, { id, accountId: m.ownerId, repoId: m.repoId, kind: "sandbox" }); |
| 2039 | const stub = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(id)); |
| 2040 | try { |
| 2041 | await stub.boot({ |
| 2042 | DIFFINERY_CLONE_URL: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}.git`, |
| 2043 | DIFFINERY_REF: ref ?? m.defaultBranch, |
| 2044 | DIFFINERY_JOB_TOKEN: token, |
| 2045 | DIFFINERY_SANDBOX_SECRET: secret, |
| 2046 | DIFFINERY_API_URL: this.env.PUBLIC_URL, |
| 2047 | GIT_AUTHOR_NAME: caller.login, |
| 2048 | GIT_AUTHOR_EMAIL: `${caller.login}@users.noreply.diffinery.com`, |
| 2049 | GIT_COMMITTER_NAME: caller.login, |
| 2050 | GIT_COMMITTER_EMAIL: `${caller.login}@users.noreply.diffinery.com`, |
| 2051 | }, secret, id, { id, accountId: m.ownerId, repoId: m.repoId }); |
| 2052 | } catch (e) { |
| 2053 | console.error("sandbox boot failed", e); |
| 2054 | this.sql.exec("UPDATE sandboxes SET status = 'failed' WHERE id = ?", id); |
| 2055 | await endCompute(this.env, id).catch(() => {}); |
| 2056 | await stub.shutdown().catch(() => {}); |
| 2057 | throw new HttpError(503, "the sandbox container did not start; try again"); |
| 2058 | } |
| 2059 | return { id, ref: ref ?? m.defaultBranch }; |
| 2060 | } |
| 2061 | |
| 2062 | getSandbox(id: string, caller: Caller): { id: string; ref: string; status: string; createdAt: string } { |
| 2063 | const s = this.sql.exec<{ id: string; ref: string; status: string; created_at: string; created_by: string; kind: string }>("SELECT * FROM sandboxes WHERE id = ?", id).toArray()[0]; |
| 2064 | // A task's sandbox holds a credential for the person's model access: nobody reaches it through the sandbox API. |
| 2065 | if (!s || s.kind === "task") throw new HttpError(404, "sandbox not found"); |
| 2066 | const owner = JSON.parse(s.created_by) as Caller; |
| 2067 | if (owner.id !== caller.id && !atLeast(caller.role, "admin")) throw new HttpError(403, "not your sandbox"); |
| 2068 | return { id: s.id, ref: s.ref, status: s.status, createdAt: s.created_at }; |
| 2069 | } |
| 2070 | |
| 2071 | async closeSandbox(id: string, caller: Caller): Promise<void> { |
| 2072 | this.getSandbox(id, caller); |
| 2073 | this.sql.exec("UPDATE sandboxes SET status = 'closed' WHERE id = ?", id); |
| 2074 | await endCompute(this.env, id); |
| 2075 | await this.env.SANDBOX.get(this.env.SANDBOX.idFromName(id)).shutdown(); |
| 2076 | } |
| 2077 | |
| 2078 | /** Close an agent task's sandbox (agentic/), which revokes its git credential. */ |
| 2079 | async closeTaskSandbox(id: string): Promise<void> { |
| 2080 | const s = this.sql.exec<{ kind: string }>("SELECT kind FROM sandboxes WHERE id = ?", id).toArray()[0]; |
| 2081 | if (s?.kind !== "task") return; |
| 2082 | this.sql.exec("UPDATE sandboxes SET status = 'closed' WHERE id = ?", id); |
| 2083 | await endCompute(this.env, id); |
| 2084 | await this.env.SANDBOX.get(this.env.SANDBOX.idFromName(id)).shutdown(); |
| 2085 | } |
| 2086 | } |