Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
1,439 linesCodeBlameRaw
1import { Container, type StopParams } from "@cloudflare/containers";
2import { WorkerEntrypoint } from "cloudflare:workers";
3
4import {
5 type BumpArgs,
6 UPDATE_BRANCH_PREFIX,
7 type RunKind,
8 agentsClient,
9 type G1tEvent,
10 type QueueJob,
11 type RepoPath,
12 type Result,
13 type ServiceBinding,
14 type User,
15 type Viewer,
16 type ComputeEntitlements,
17 type ComputeKind,
18 ComputeGate,
19 actualMicros,
20 eventsClient,
21 refusalMessage,
22 sandboxEstimateMicros,
23 billingClient,
24 fail,
25 ok,
26 reposClient,
27 workClient,
28 type InstanceType,
29 STANDARD_INSTANCE,
30 instanceNamed,
31 priceScale,
32 runnerMinutes,
33} from "@g1t/contracts";
34
35import { BUMP_MINUTES, BUMP_TOKEN_TTL_SECONDS, bumpEnv, bumpProblem, bumpSandboxName, systemActor } from "./bump";
36import { BACKUP_MINUTES, backupEnv, backupPace, backupSandboxName } from "./backup";
37import { holdCredentials, revokeCredentials, runCredential } from "./credentials";
38import { cancelTask, enqueueTask, handedOverStep, selfHostedRoute, taskEnv, taskRepo } from "./self-hosted";
39import { answered, capReached, describeError, type Owed, recordOwed, stoppedShort, tellStopped, withinTimeCap } from "./lifecycle";
40import {
41 ABUSE_EXIT_CODE,
42 ABUSE_HOST,
43 ABUSE_MESSAGE,
44 ALARM_GRACE_SECONDS,
45 type PlanLimits,
46 type RunGuard,
47 abuse,
48 buildGuard,
49 dockerFor,
50 egress,
51 guardFor,
52 harnessEnv,
53 reportRun,
54 SANDBOX_BINDINGS,
55 sandboxNamespace,
56 throughWorker,
57 timeCapMessage,
58 withPlanLimits,
59} from "./guard";
60import { newlyMisdirected } from "./broker";
61import { relay, relayDeps } from "./broker-relay";
62import { forward } from "./forward";
63import { EGRESS_ENV, jobNamespaceOf } from "./egress";
64
65// Outbound interception, which brokered secrets and abuse reports use,
66// needs this exported.
67export { ContainerProxy } from "@cloudflare/containers";
68
69export interface RunnerEnv {
70 SANDBOX: DurableObjectNamespace<AttemptSandbox>;
71 /**
72 * Workflow jobs that ask with `runs-on` for `g1t-2core` (the standard
73 * machine, kept as a name so the label keeps working) or `g1t-4core` (a
74 * larger instance type): the same image, in classes of their own.
75 */
76 SANDBOX_2CORE?: DurableObjectNamespace<Sandbox2Core>;
77 SANDBOX_4CORE?: DurableObjectNamespace<Sandbox4Core>;
78 IDENTITY: ServiceBinding;
79 REPOS: ServiceBinding;
80 WORK: ServiceBinding;
81 BILLING: ServiceBinding;
82 /** GitHub Actions jobs: told when a job's sandbox dies without reporting. */
83 ACTIONS: ServiceBinding;
84 /** Told when a deploy sandbox dies without reporting. */
85 DEPLOYMENTS: ServiceBinding;
86 /** The event bus: `abuse.flagged`, for g1t's staff. */
87 EVENTS?: ServiceBinding;
88 CLOUDFLARE_ACCOUNT_ID: string;
89 /**
90 * `off` stops passing any sandbox's HTTP and HTTPS through the Worker: a
91 * switch for the operator, should it misbehave. Brokered secrets' stand-
92 * ins then go out as they are, unfilled. Anything else passes the
93 * requests of workflow jobs that may be given brokered secrets.
94 */
95 EGRESS?: string;
96 /**
97 * `off` stops sandboxes watching themselves for mining (crates/runner
98 * abuse.rs): a switch for the operator, should it stop real work.
99 * Anything else leaves it on. Miners named in commands are refused
100 * either way.
101 */
102 ABUSE_WATCH?: string;
103 /**
104 * `off` leaves workflow jobs without a Docker Engine of their own
105 * (crates/runner docker/): a switch for the operator. Anything else
106 * gives each job one, started the first time it is used.
107 */
108 DOCKER?: string;
109 /**
110 * Nightly backups (backup.ts): how many queued backups one sweep starts
111 * (`0`: none, backups off here), and how many may run at once.
112 */
113 BACKUPS_PER_SWEEP?: string;
114 BACKUPS_RUNNING?: string;
115}
116
117/**
118 * What a sandbox is doing. Runs of an issue's commands (`checks`) are from
119 * before checks were workflows; a sandbox that was started for work g1t no
120 * longer does is told apart by its kind and only cleaned up.
121 */
122type Run =
123 | { kind: "checks"; runId: string; token: string }
124 /** One combined state of a merge queue, being built and checked. */
125 | { kind: "queue"; entryId: string; token: string }
126 /** Whether a pull request merges cleanly: two commits merged, nothing pushed. */
127 | { kind: "mergecheck"; pullId: string; token: string }
128 /** One job of a GitHub Actions workflow. */
129 | { kind: "actions"; jobId: string; token: string }
130 /** A build of one commit, deployed to g1t.page. */
131 | { kind: "deploy"; deployId: string; token: string }
132 /**
133 * A security update: one package raised in its lockfiles and pushed to
134 * its branch. The security service opens the pull request when it hears
135 * the push, so a failure has no one to tell.
136 */
137 | { kind: "bump"; repo: RepoPath; branch: string }
138 /**
139 * A repository's nightly backup: a bundle cut and sent to the repos
140 * service. g1t's own work, never charged to the workspace.
141 */
142 | { kind: "backup"; jobId: string; token: string };
143/**
144 * Whose sandbox time it is, reported when the sandbox stops, and the
145 * machine it ran on when it was not the standard one.
146 */
147type Meter = {
148 workspace: string;
149 repo: string;
150 description: string;
151 instance?: string | null;
152};
153/** A stopped sandbox's time, as billing records it (`recordSandbox`). */
154type SandboxUsage = Parameters<ReturnType<typeof billingClient>["recordSandbox"]>[0];
155/**
156 * What billing reserved for a sandbox's work (`ComputeGate.admit`), settled
157 * when it stops at what it cost: its seconds.
158 */
159type Held = { id: string; workspace: string; microsPerSecond: number };
160/**
161 * A sandbox that is not tracked as a run but still has a time cap: a
162 * workflow job, a deploy build or an update, for `minutes` at most.
163 */
164type Build = {
165 kind: "actions" | "deploy" | "bump";
166 minutes: number;
167 /**
168 * A workflow job that may be given brokered secrets: its HTTP and HTTPS
169 * pass the Worker, which fills them in.
170 */
171 brokered?: boolean;
172};
173/** Deploy builds are metered by the Deployments plan, not here. */
174type RunRequest = Run & {
175 envVars: Record<string, string>;
176 meter?: Meter;
177 track?: Track;
178 /** The workspace's plan's caps, applied under its guardrails' (lower of each). */
179 limits?: PlanLimits;
180 reservation?: Held | null;
181 build?: Build;
182 /** Whose sandbox it is, when it has no meter: for `abuse.flagged`. */
183 owner?: { workspace: string; repo: string };
184 /**
185 * The labels of the workspace's self-hosted runners this work goes to
186 * instead of a container (self-hosted.ts). Null or absent: a container.
187 */
188 selfHosted?: string[] | null;
189};
190
191/** What a sandbox is, as billing meters it. */
192function computeKindOf(kind: Run["kind"]): ComputeKind | null {
193 switch (kind) {
194 case "checks":
195 case "mergecheck":
196 // A security update resolves lockfiles, as cheap as a check.
197 case "bump":
198 return "check";
199 case "queue":
200 return "queue";
201 case "actions":
202 return "workflow";
203 case "deploy":
204 return "deploy";
205 // Not metered: a backup is g1t's own cost; nor is a sandbox of a kind
206 // g1t no longer starts, which ran before it stopped starting them.
207 default:
208 return null;
209 }
210}
211
212/** One gate per isolate, so entitlements and prices are kept between calls. */
213let gate: ComputeGate | null = null;
214function gateFor(env: { BILLING: ServiceBinding }): ComputeGate {
215 gate ??= new ComputeGate(env.BILLING);
216 return gate;
217}
218
219/**
220 * What to record the sandbox as, so people can watch it: a run of the
221 * merge queue.
222 */
223type Track = {
224 actor: User;
225 repo: RepoPath;
226 kind: RunKind;
227 number?: number | null;
228 pullId?: string | null;
229 title?: string | null;
230 startedBy?: string | null;
231};
232/** The run a sandbox reports to, kept so it can be closed when it stops. */
233type TrackedRun = { runId: string; token: string };
234
235function meter(repo: RepoPath, description: string): Meter {
236 return { workspace: repo.namespace, repo: `${repo.namespace}/${repo.name}`, description };
237}
238
239/** What the deployments service asks a sandbox to build. */
240type DeployJob = {
241 deployId: string;
242 /** The workspace the project is in, which pays. */
243 workspace?: string;
244 /** What the deployments service reserved for the build, settled when it stops. */
245 reservation?: string | null;
246 /** The price it reserved at, per second. */
247 microsPerSecond?: number | null;
248 /** The plan's longest run, in minutes; the build gets the lower of this and its own. */
249 maxRunMinutes?: number | null;
250 /** Lets the sandbox, and nothing else, report this build. */
251 token: string;
252 /** Whose access reads the commit. */
253 actor: User;
254 /** The repository the commit is in: the pull request's fork, or the repository. */
255 source: RepoPath;
256 /**
257 * The project's repository, whose guardrails the build runs under, and
258 * its id. A preview's `source` is its pull request's working copy, so
259 * the two differ. Older callers send only `source`.
260 */
261 repo?: RepoPath | null;
262 repoId?: string | null;
263 commit: string;
264 /** Where in the repository the project lives; empty for all of it. */
265 rootDir?: string;
266 buildCommand?: string | null;
267 outputDir?: string | null;
268 /** What it is built as (`auto`, `static`, `workers` or `docker`), and its Dockerfile's path when set. */
269 buildAs?: string | null;
270 dockerfile?: string | null;
271 /** The repository's variables for deploy builds. */
272 buildEnv?: Record<string, string>;
273 /** Its secrets for deploy builds: set like variables, and redacted from the log. */
274 buildSecrets?: Record<string, string>;
275};
276
277/** Long enough to install and build; then the read token stops working. */
278const DEPLOY_TOKEN_TTL_SECONDS = 30 * 60;
279
280/** Long enough to clone, build and test a merge queue's state; then the token stops working. */
281const CHECKS_TOKEN_TTL_SECONDS = 45 * 60;
282
283/** Long enough to clone and merge two commits; then the read token stops working. */
284const MERGECHECK_TOKEN_TTL_SECONDS = 10 * 60;
285
286/**
287 * One sandbox, for one piece of work: a workflow job, a build, a state of
288 * the merge queue, a merge check, an update or a backup. The image's entrypoint
289 * is the g1t runner, which does the work and exits; this class only starts
290 * it and cleans up if it dies without reporting.
291 */
292export class AttemptSandbox extends Container<RunnerEnv> {
293 // Past the longest default time cap (90 minutes) and its alarm. A run whose guardrails allow longer (up to 240 minutes) is kept
294 // past it by `onActivityExpired`, so only its own cap ends it. A finished
295 // run's process exits and stops the sandbox well before this.
296 sleepAfter = "100m";
297 // HTTPS to an intercepted host goes through its handler too (guard.ts).
298 interceptHttps = true;
299 static {
300 // Assigned, not declared: a class field would hide the setter that
301 // registers the handler with the containers library. Which one a
302 // sandbox uses is chosen as it starts (`run`).
303 AttemptSandbox.outboundHandlers = { egress, abuse };
304 }
305
306 async run(request: RunRequest): Promise<void> {
307 const { envVars, meter, track, limits, reservation, build, owner, selfHosted, ...run } = request;
308 // What billing reserved is settled however this ends, once.
309 if (reservation) await this.ctx.storage.put("reservation", reservation);
310 let guard: RunGuard | null;
311 try {
312 // A tracked run gets its project's guardrails; no sandbox for one
313 // starts without them. Workflow jobs, deploy builds and updates get
314 // the time cap they were given. The plan's caps apply under them:
315 // the lower of each.
316 guard = track
317 ? withPlanLimits(await guardFor(this.env.WORK, track.repo, track.kind), limits)
318 : build
319 ? withPlanLimits(buildGuard(build.minutes), limits)
320 : null;
321 } catch (error) {
322 // Thrown to the caller, which says why the work did not start; logged
323 // here too, so a sandbox that never started is traceable on its own.
324 console.error("sandbox not started", run.kind, describeError(error));
325 await this.settle(0);
326 throw error;
327 }
328 await this.ctx.storage.put("run", run);
329 // What an earlier attempt in this object left: its reason, its cap
330 // and its cap's alarm, which would otherwise stop this attempt when
331 // the earlier one's time ran out (re-runs use the same object).
332 await this.ctx.storage.delete(["abuse", "stopReason", "remote", "misdirected", "timeCap"]);
333 this.deleteSchedules("timeUp");
334 if (meter) await this.ctx.storage.put("meter", { ...meter, started: Date.now() });
335 await this.ctx.storage.put("started", Date.now());
336 const who = meter ? { workspace: meter.workspace, repo: meter.repo } : owner;
337 if (who) await this.ctx.storage.put("owner", { ...who, kind: track?.kind ?? run.kind });
338 const tracked = track ? await this.openRun(track, envVars, guard) : null;
339 // Its credentials are tied to the run, and revoked when it stops.
340 await holdCredentials(this.env.IDENTITY, this.ctx.storage, envVars, tracked?.runId ?? null);
341 try {
342 const vars = tracked ? { ...envVars, AGENT_RUN: tracked.runId, AGENT_RUN_TOKEN: tracked.token } : envVars;
343 // The workspace's own runner, not a container: the same environment,
344 // handed over as a task.
345 const repo = selfHosted?.length ? taskRepo(track, meter, owner) : null;
346 if (selfHosted?.length && repo) {
347 const harness = guard ? harnessEnv(guard, vars) : {};
348 const minutes = guard?.minutes ?? limits?.minutes ?? 60;
349 await enqueueTask(this.env.ACTIONS, {
350 sandbox: this.ctx.id.toString(),
351 repo,
352 kind: track?.kind ?? run.kind,
353 title: track?.title ?? meter?.description ?? `${run.kind} in ${repo.namespace}/${repo.name}`,
354 labels: selfHosted,
355 env: taskEnv({ ...vars, ...harness }),
356 timeoutMinutes: minutes,
357 runId: tracked?.runId ?? null,
358 });
359 await this.ctx.storage.put("remote", true);
360 if (tracked) await reportRun(this.env.WORK, tracked, { steps: [handedOverStep(selfHosted)] });
361 if (guard) {
362 await this.ctx.storage.put("timeCap", guard.minutes);
363 await this.schedule(guard.minutes * 60 + ALARM_GRACE_SECONDS, "timeUp");
364 }
365 return;
366 }
367 // Every sandbox reaches the internet. A workflow job that may be
368 // given brokered secrets sends its HTTP and HTTPS through `egress`,
369 // which fills them in from the job's stand-ins (asked for with the
370 // job's token) and sends every request on, to any host; any other
371 // sandbox has only `ABUSE_HOST` intercepted, so it can report that it
372 // stopped itself for mining. Both are chosen here, as it starts: hosts
373 // named on the class up front (`outboundByHost` in a static block)
374 // left sandboxes that never picked up their jobs (rolled back
375 // 2026-10-13).
376 const brokering = run.kind === "actions" && throughWorker(build, this.env.EGRESS);
377 this.enableInternet = true;
378 if (brokering && run.kind === "actions") {
379 await this.setOutboundHandler("egress", { job: { id: run.jobId, token: run.token } });
380 } else if (this.env.EGRESS?.trim().toLowerCase() !== "off") {
381 await this.setOutboundByHost(ABUSE_HOST, "abuse").catch((error: unknown) =>
382 console.log("abuse reports not routed", String(error)),
383 );
384 }
385 const harness = guard && !build ? harnessEnv(guard, vars) : {};
386 // Once any of its traffic is intercepted, Cloudflare passes all of it
387 // through this Worker, and HTTPS is re-signed on the way
388 // (`interceptHttps`); so a sandbox with either handler trusts that
389 // certificate, or every HTTPS request it makes fails
390 // (SELF_SIGNED_CERT_IN_CHAIN, as on 2026-10-13).
391 const intercepted = brokering || this.env.EGRESS?.trim().toLowerCase() !== "off";
392 const trust = intercepted ? EGRESS_ENV : {};
393 const watch: Record<string, string> = this.env.ABUSE_WATCH === "off" ? { G1T_ABUSE: "off" } : {};
394 await this.start({ envVars: { ...vars, ...harness, ...trust, ...watch }, enableInternet: true });
395 // A backup has no guardrails, but still a time cap.
396 const cap = guard?.minutes ?? (run.kind === "backup" ? BACKUP_MINUTES : null);
397 if (cap) {
398 await this.ctx.storage.put("timeCap", cap);
399 await this.schedule(cap * 60 + ALARM_GRACE_SECONDS, "timeUp");
400 }
401 } catch (error) {
402 console.error("sandbox not started", run.kind, describeError(error));
403 await revokeCredentials(this.env.IDENTITY, this.ctx.storage);
404 if (tracked) await this.closeRun("failed", `The sandbox could not start: ${String(error)}`);
405 await this.settle(0);
406 throw error;
407 }
408 }
409
410 /** Settles what billing reserved for this sandbox at `micros`, once. */
411 private async settle(micros: number): Promise<void> {
412 const held = await this.ctx.storage.get<Held>("reservation");
413 if (!held) return;
414 await this.ctx.storage.delete("reservation");
415 await gateFor(this.env).settle(held.id, micros);
416 }
417
418 /** Settles the reservation at what the sandbox cost: its seconds at the price billing reserved at. */
419 private async settleStopped(started: number | undefined): Promise<void> {
420 const held = await this.ctx.storage.get<Held>("reservation");
421 if (!held) return;
422 const seconds = started ? Math.max(1, Math.ceil((Date.now() - started) / 1000)) : 0;
423 await this.settle(actualMicros(seconds, held.microsPerSecond));
424 }
425
426 /**
427 * The sandbox stopped itself because it looked like it was mining
428 * (crates/runner abuse.rs), or exited saying so. Stops the run with
429 * `ABUSE_MESSAGE`, tells g1t's staff with `abuse.flagged`, and destroys
430 * the sandbox. Once.
431 */
432 async flagAbuse(verdict: unknown): Promise<void> {
433 if (await this.ctx.storage.get<boolean>("abuse")) return;
434 await this.ctx.storage.put("abuse", true);
435 const tracked = await this.ctx.storage.get<TrackedRun>("agentRun");
436 if (tracked) await reportRun(this.env.WORK, tracked, { halt: "abuse", error: ABUSE_MESSAGE });
437 const owner = await this.ctx.storage.get<{ workspace: string; repo: string | null; kind: string }>("owner");
438 console.log("abuse flagged", owner?.workspace, owner?.repo, owner?.kind, JSON.stringify(verdict));
439 if (this.env.EVENTS && owner) {
440 await eventsClient(this.env.EVENTS)
441 .publish([
442 {
443 type: "abuse.flagged",
444 source: "runner",
445 // Never on a repository's timeline or its webhooks.
446 repoId: null,
447 actor: null,
448 data: {
449 workspace: owner.workspace,
450 repo: owner.repo ?? null,
451 run: tracked?.runId ?? null,
452 kind: owner.kind,
453 sandbox: this.ctx.id.toString(),
454 metrics: verdict && typeof verdict === "object" ? (verdict as Record<string, unknown>) : null,
455 },
456 },
457 ])
458 .catch((error: unknown) => console.log("abuse.flagged not published", String(error)));
459 }
460 await this.destroy().catch((error: unknown) => console.log("sandbox not destroyed for abuse", String(error)));
461 }
462
463 /**
464 * Records the run, which the sandbox then reports its steps to. Never
465 * stops the sandbox from starting: without a record it just goes unseen.
466 */
467 private async openRun(track: Track, envVars: Record<string, string>, guard: RunGuard | null): Promise<TrackedRun | null> {
468 const opened = await agentsClient(this.env.WORK)
469 .openRun({
470 ...track,
471 model: null,
472 sandbox: this.ctx.id.toString(),
473 timeCapMinutes: guard?.minutes ?? null,
474 })
475 .catch((error: unknown) => ({ ok: false as const, error: { message: String(error) } }));
476 if (!opened.ok) {
477 console.log("run not recorded", track.kind, opened.error.message);
478 return null;
479 }
480 await this.ctx.storage.put("agentRun", opened.value);
481 return opened.value;
482 }
483
484 /**
485 * A workflow job sent a brokered secret's stand-in to a host it is not
486 * for: said once per secret and host, in the job's log.
487 */
488 async noteMisdirected(name: string, host: string): Promise<void> {
489 const run = await this.ctx.storage.get<Run>("run");
490 if (run?.kind !== "actions") return;
491 const noted = newlyMisdirected((await this.ctx.storage.get<string[]>("misdirected")) ?? [], name, host);
492 if (!noted) return;
493 await this.ctx.storage.put("misdirected", noted.seen);
494 try {
495 const response = await this.env.ACTIONS.fetch("https://actions/rpc/job_report", {
496 method: "POST",
497 headers: { "content-type": "application/json" },
498 body: JSON.stringify({ job: run.jobId, token: run.token, report: { kind: "note", text: noted.note } }),
499 });
500 await answered("job_report", response);
501 } catch (error) {
502 console.log("misdirected secret not noted", name, String(error));
503 }
504 }
505
506 /** The run's time cap has passed: stop it, as stopped for time. */
507 async timeUp(): Promise<void> {
508 // It already stopped (nothing to stop), or the run in it now is a later
509 // attempt whose cap has not passed: this alarm is an earlier attempt's,
510 // left by a version of g1t that did not clear it, and the run now has
511 // its own.
512 const started = await this.ctx.storage.get<number>("started");
513 const minutes = (await this.ctx.storage.get<number>("timeCap")) ?? 0;
514 if (!capReached(started, minutes, Date.now())) {
515 if (started) console.log("an earlier attempt's time cap passed; the run now keeps going", this.ctx.id.toString());
516 return;
517 }
518 const tracked = await this.ctx.storage.get<TrackedRun>("agentRun");
519 // A workflow job or a build has no run to halt: it fails saying why.
520 await this.ctx.storage.put("stopReason", timeCapMessage(minutes));
521 if (tracked) await reportRun(this.env.WORK, tracked, { halt: "time", error: timeCapMessage(minutes) });
522 if (await this.ctx.storage.get<boolean>("remote")) {
523 await cancelTask(this.env.ACTIONS, this.ctx.id.toString(), timeCapMessage(minutes));
524 await this.remoteEnded(1, null);
525 return;
526 }
527 await this.destroy().catch((error: unknown) => console.log("sandbox not destroyed at its time cap", String(error)));
528 }
529
530 /**
531 * Stops this sandbox's work: its container, or the task a self-hosted
532 * runner holds, which it hears about on its next poll.
533 */
534 async halt(reason: string | null): Promise<void> {
535 if (await this.ctx.storage.get<boolean>("remote")) {
536 await cancelTask(this.env.ACTIONS, this.ctx.id.toString(), reason);
537 await this.remoteEnded(1, reason);
538 return;
539 }
540 // A container already gone has nothing to stop; one that will not stop
541 // is logged, and its time cap still ends it.
542 await this.destroy().catch((error: unknown) => console.error("sandbox not destroyed", reason, describeError(error)));
543 }
544
545 /**
546 * The library's `sleepAfter` has passed. The runner never fetches its
547 * container, so to the library every sandbox looks idle: a run inside its
548 * time cap keeps going, and the cap's own alarm (`timeUp`) ends it. Only
549 * a sandbox with no cap is stopped for inactivity.
550 */
551 override async onActivityExpired(): Promise<void> {
552 const started = await this.ctx.storage.get<number>("started");
553 const cap = await this.ctx.storage.get<number>("timeCap");
554 if (withinTimeCap(started, cap, Date.now(), ALARM_GRACE_SECONDS)) return;
555 await super.onActivityExpired();
556 }
557
558 /**
559 * A container that crashed or could not be reached, as the library tells
560 * it. Logged at error level with the sandbox, never thrown: the library
561 * ignores what this throws, and the stop that follows is handled by
562 * `onStop` or by `run`, which says why the work did not start.
563 */
564 override onError(error: unknown): void {
565 console.error("sandbox container error", this.ctx.id.toString(), describeError(error));
566 }
567
568 /**
569 * The library's alarm: scheduled callbacks, the container's keep-alive,
570 * and `onStop` once it has stopped. A failure is logged with the retry it
571 * was, then thrown so Cloudflare tries the alarm again.
572 */
573 override async alarm(alarmProps?: AlarmInvocationInfo): Promise<void> {
574 try {
575 await super.alarm(alarmProps);
576 } catch (error) {
577 console.error("sandbox alarm failed", this.ctx.id.toString(), `retry ${alarmProps?.retryCount ?? 0}`, describeError(error));
578 throw error;
579 }
580 }
581
582 /**
583 * A self-hosted runner's task ended (the actions service says so, or g1t
584 * stopped it): everything a container's stop does, once.
585 */
586 async remoteEnded(exitCode: number, reason: string | null): Promise<void> {
587 if (!(await this.ctx.storage.get<boolean>("remote"))) return;
588 await this.ctx.storage.delete("remote");
589 await this.ctx.storage.put("selfHostedEnded", true);
590 if (exitCode !== 0 && reason && !(await this.ctx.storage.get<string>("stopReason"))) {
591 await this.ctx.storage.put("stopReason", reason);
592 }
593 await this.onStop({ exitCode, reason: "exit" } as StopParams);
594 await this.ctx.storage.delete("selfHostedEnded");
595 }
596
597 /**
598 * Ends the run's record, once. Returns the status it ended with:
599 * `stopped` when a person stopped it first.
600 */
601 private async closeRun(outcome: "succeeded" | "failed", error?: string): Promise<string | null> {
602 const tracked = await this.ctx.storage.get<TrackedRun>("agentRun");
603 if (!tracked) return null;
604 await this.ctx.storage.delete("agentRun");
605 const closed = await agentsClient(this.env.WORK)
606 .closeRun(tracked.runId, tracked.token, outcome, error)
607 .catch(() => null);
608 return closed?.ok ? closed.value : null;
609 }
610
611 /**
612 * Reports how long the sandbox ran, once, whatever it exited with. The
613 * usage is fixed now and kept (`owed:<reference>`) until billing
614 * confirms it, tried again from the object's alarm while it does not: a
615 * re-run in this object writes a new `meter`, never over what is owed.
616 * Billing records a reference once, so a try whose answer was lost
617 * cannot charge twice. The reservation is settled apart (`settleStopped`).
618 */
619 private async meterStop(): Promise<void> {
620 const metered = await this.ctx.storage.get<Meter & { started: number }>("meter");
621 if (!metered) return;
622 const seconds = Math.max(1, Math.ceil((Date.now() - metered.started) / 1000));
623 const run = await this.ctx.storage.get<Run>("run");
624 // On the workspace's own runner: its minutes, at $0.
625 const selfHosted = (await this.ctx.storage.get<boolean>("selfHostedEnded")) ?? false;
626 const usage: SandboxUsage = {
627 workspace: metered.workspace,
628 seconds,
629 description: selfHosted ? `${metered.description} on a self-hosted runner` : metered.description,
630 repo: metered.repo,
631 reference: `sandbox/${this.ctx.id.toString()}/${metered.started}`,
632 // Whether g1t's open-source pool may pay for it.
633 kind: run ? computeKindOf(run.kind) : null,
634 selfHosted,
635 instance: metered.instance ?? null,
636 };
637 // One write: the meter becomes what is owed.
638 await this.ctx.storage.put<Owed<SandboxUsage>>(`owed:${usage.reference}`, { usage, attempts: 0, since: Date.now() });
639 await this.ctx.storage.delete("meter");
640 await this.recordOwedTime();
641 }
642
643 /**
644 * Records the sandbox time billing has not confirmed, and schedules the
645 * next try for what it still has not. Never throws: it runs in the stop
646 * hook and in the object's alarm.
647 */
648 async recordOwedTime(): Promise<void> {
649 const owed = [...(await this.ctx.storage.list<Owed<SandboxUsage>>({ prefix: "owed:" })).values()];
650 if (!owed.length) return;
651 const billing = billingClient(this.env.BILLING);
652 const result = await recordOwed(
653 owed,
654 async (usage) => {
655 const recorded = await billing.recordSandbox(usage);
656 if (!recorded.ok) console.log("sandbox time not recorded yet", usage.workspace, usage.seconds, recorded.error.message);
657 return recorded.ok;
658 },
659 Date.now(),
660 );
661 for (const entry of result.recorded) await this.ctx.storage.delete(`owed:${entry.usage.reference}`);
662 for (const entry of result.kept) await this.ctx.storage.put(`owed:${entry.usage.reference}`, entry);
663 for (const entry of result.lost) {
664 console.error("sandbox time not recorded, given up", entry.usage.reference, entry.usage.workspace, entry.usage.seconds, `after ${entry.attempts} tries`);
665 await this.ctx.storage.delete(`owed:${entry.usage.reference}`);
666 }
667 this.deleteSchedules("recordOwedTime");
668 if (result.retryInSeconds !== null) {
669 await this.schedule(result.retryInSeconds, "recordOwedTime").catch((error: unknown) =>
670 console.error("sandbox time retry not scheduled", this.ctx.id.toString(), describeError(error)),
671 );
672 }
673 }
674
675 override async onStop({ exitCode, reason }: StopParams): Promise<void> {
676 await revokeCredentials(this.env.IDENTITY, this.ctx.storage);
677 const started = await this.ctx.storage.get<number>("started");
678 await this.meterStop();
679 // It stopped itself for mining, and could not say so before it went.
680 if (exitCode === ABUSE_EXIT_CODE && !(await this.ctx.storage.get<boolean>("abuse"))) {
681 await this.flagAbuse(null);
682 }
683 const flagged = (await this.ctx.storage.get<boolean>("abuse")) ?? false;
684 // Why it stopped, when g1t stopped it: said in place of a plain failure.
685 const why = flagged ? ABUSE_MESSAGE : ((await this.ctx.storage.get<string>("stopReason")) ?? null);
686 // Stopped by g1t, it failed whatever exit code the library passed on.
687 const short = stoppedShort(exitCode, why);
688 await this.closeRun(
689 short ? "failed" : "succeeded",
690 short ? (why ?? `The sandbox exited with ${exitCode}.`) : undefined,
691 );
692 await this.settleStopped(started);
693 await this.ctx.storage.delete("started");
694 if (!short) return;
695 const run = await this.ctx.storage.get<Run>("run");
696 console.log("sandbox stopped", run?.kind, "exit", exitCode, reason);
697 if (!run) return;
698 // Never thrown: this runs in the sandbox's alarm, which a throw would
699 // fail, retry and count as an error, running all of the above again.
700 // What could not be told is logged, and the sweep catches it up.
701 await tellStopped(run.kind, () => this.reportStopped(run, why, exitCode));
702 }
703
704 /**
705 * Tells whoever is waiting on the sandbox's work that it stopped without
706 * finishing it. Each is refused harmlessly when the sandbox reported its
707 * end before it stopped. Throws when the service could not be reached.
708 */
709 private async reportStopped(run: Run, why: string | null, exitCode: number): Promise<void> {
710 if (run.kind === "actions") {
711 // Refused harmlessly if the job reported its end before it stopped.
712 const response = await this.env.ACTIONS.fetch("https://actions/rpc/job_report", {
713 method: "POST",
714 headers: { "content-type": "application/json" },
715 body: JSON.stringify({
716 job: run.jobId,
717 token: run.token,
718 report: { kind: "done", conclusion: "failure", reason: why ?? "The runner stopped before the job finished." },
719 }),
720 });
721 await answered("job_report", response);
722 return;
723 }
724 // Nothing was pushed, so no pull request opens; why is in its log.
725 if (run.kind === "bump") return;
726 if (run.kind === "backup") {
727 // Refused harmlessly if the sandbox reported before it stopped; the
728 // job is otherwise tried again later tonight.
729 await reposClient(this.env.REPOS).failBackup(run.jobId, run.token, why ?? `The sandbox exited with ${exitCode}.`);
730 return;
731 }
732 if (run.kind === "deploy") {
733 // Refused harmlessly if the build reported its end before it stopped.
734 const response = await this.env.DEPLOYMENTS.fetch(`https://deployments/jobs/${run.deployId}/fail`, {
735 method: "POST",
736 headers: { "content-type": "application/json" },
737 body: JSON.stringify({ token: run.token, message: why ?? "The build stopped before it finished." }),
738 });
739 await answered("deploy fail", response);
740 return;
741 }
742 const work = workClient(this.env.WORK);
743 if (run.kind === "checks") {
744 // Refused harmlessly if the run did report before it stopped.
745 await work.reportChecks(run.runId, run.token, {
746 error: why ?? "The sandbox stopped before the checks finished.",
747 });
748 return;
749 }
750 if (run.kind === "queue") {
751 // Refused harmlessly if the state was reported before it stopped.
752 await work.failQueue(run.entryId, run.token, why ?? "The sandbox stopped before the state was checked.");
753 return;
754 }
755 if (run.kind === "mergecheck") {
756 // Refused harmlessly if the probe reported before it stopped.
757 await work.failMergecheck(run.pullId, run.token, why ?? "The sandbox stopped before the merge check finished.");
758 return;
759 }
760 // A sandbox of a kind g1t no longer starts, which ran from before: it
761 // has nobody left to tell.
762 }
763}
764
765/**
766 * What the compute gate decided for one start: go, with what billing
767 * reserved and the plan's caps; or not, refused with what to tell people.
768 */
769type Granted = {
770 ok: true;
771 held: Held | null;
772 limits: PlanLimits;
773 /** The labels of the self-hosted runners it goes to; null for a sandbox. */
774 route: string[] | null;
775};
776type Admitted = Granted | { ok: false; code: string; message: string };
777
778/**
779 * The guardrails' default time cap of each kind of run, for estimating what
780 * it may cost before it starts; the sandbox applies the project's own.
781 */
782const DEFAULT_MINUTES: Record<"queue" | "mergecheck", number> = {
783 queue: 45,
784 mergecheck: 10,
785};
786
787/** A plan's cap on one run, for the sandbox to apply under its guardrails'. */
788function limitsOf(ent: ComputeEntitlements | null): PlanLimits {
789 return { minutes: ent && ent.maxRunMinutes > 0 ? ent.maxRunMinutes : null };
790}
791
792/** The shorter of a kind's time cap and the plan's, for an estimate. */
793function estimateMinutes(minutes: number, ent: ComputeEntitlements | null): number {
794 return ent && ent.maxRunMinutes > 0 ? Math.min(minutes, ent.maxRunMinutes) : minutes;
795}
796
797/** A start the gate did not let through, as a result for whoever asked. */
798function notAdmitted(admitted: Exclude<Admitted, Granted>): Result<never> {
799 return fail("payment_required", admitted.message);
800}
801
802/**
803 * Larger sandboxes for workflow jobs that ask for one in `runs-on`: the
804 * same image and behaviour on a larger Containers instance type, each a
805 * class of its own (wrangler.jsonc). Outbound handlers are registered by
806 * class, so each registers its own.
807 */
808export class Sandbox2Core extends AttemptSandbox {
809 static {
810 Sandbox2Core.outboundHandlers = { egress, abuse };
811 }
812}
813export class Sandbox4Core extends AttemptSandbox {
814 static {
815 Sandbox4Core.outboundHandlers = { egress, abuse };
816 }
817}
818
819/**
820 * What the actions service sends to start a job (`StartJobArgs`). Callers
821 * from before may also send `workflow`, `environment` and `trusted`, for
822 * workflow-only domains, which are gone: they are ignored.
823 */
824type ActionsJobArgs = {
825 job: string;
826 token: string;
827 repo: RepoPath;
828 timeoutMinutes: number;
829 /** The machine its `runs-on` asked for, by label; absent, the standard one. */
830 instance?: string | null;
831 /** Whether it may be given brokered secrets, which its requests need filled in. */
832 brokered?: boolean;
833};
834
835export default class RunnerService extends WorkerEntrypoint<RunnerEnv> {
836 /**
837 * The JSON protocol the Rust services speak: `POST /rpc/<method>` with the
838 * arguments as the body. Only bound services can.
839 */
840 async fetch(request: Request): Promise<Response> {
841 const { pathname } = new URL(request.url);
842 if (request.method === "POST" && pathname === "/rpc/start_actions_job") {
843 return Response.json(await this.startActionsJob((await request.json()) as ActionsJobArgs));
844 }
845 if (request.method === "POST" && pathname === "/rpc/stop_actions_job") {
846 const args = (await request.json()) as { job: string };
847 // Whichever machine it asked for: the job's object in every namespace.
848 await Promise.all(
849 Object.keys(SANDBOX_BINDINGS).map((className) => {
850 const namespace = sandboxNamespace(this.env, className) as unknown as DurableObjectNamespace<AttemptSandbox>;
851 return namespace
852 .get(namespace.idFromName(`actions:${args.job}`))
853 .destroy()
854 .catch(() => undefined);
855 }),
856 );
857 return Response.json(ok(true));
858 }
859 // A self-hosted runner's task ended: the sandbox that handed it over
860 // does what it does when a container stops.
861 if (request.method === "POST" && pathname === "/rpc/task_ended") {
862 const args = (await request.json()) as { sandbox: string; exitCode: number; reason?: string | null };
863 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromString(args.sandbox));
864 await sandbox.remoteEnded(args.exitCode, args.reason ?? null);
865 return Response.json(ok(true));
866 }
867 // A self-hosted job's request to a brokered secret's host, which the
868 // API passes on as it came (broker-relay.ts).
869 if (request.method === "POST" && pathname === "/rpc/broker") {
870 try {
871 return await relay(request, relayDeps(this.env.ACTIONS, (upstream) => forward(upstream)));
872 } catch (error) {
873 console.log("broker relay failed", String(error));
874 return Response.json({ error: { code: "unavailable", message: "g1t could not relay the request." } }, { status: 503 });
875 }
876 }
877 if (request.method === "POST" && pathname === "/rpc/bump") {
878 return Response.json(await this.startBump(await request.json()));
879 }
880 if (request.method === "POST" && pathname === "/rpc/start_deploy") {
881 return Response.json(await this.startDeploy((await request.json()) as DeployJob));
882 }
883 return new Response("Not found\n", { status: 404 });
884 }
885
886 // ---- The compute gate (@g1t/contracts compute.ts) -------------------------
887
888 /** Whether `repo` is public: what g1t's open-source pool can pay for. */
889 private async isPublic(repo: RepoPath): Promise<boolean> {
890 const found = await reposClient(this.env.REPOS)
891 .get(repo, null)
892 .catch(() => null);
893 return Boolean(found?.ok && !found.value.isPrivate);
894 }
895
896 /**
897 * Whether a sandbox (the merge queue, a
898 * merge check, a workflow job) may start in `repo`, with what it may cost
899 * for `minutes` reserved. Public repositories' checks, workflows and
900 * queue can be paid by the open-source pool. Never throws.
901 */
902 private async admitSandbox(
903 kind: ComputeKind,
904 repo: RepoPath,
905 minutes: number,
906 instance: InstanceType = STANDARD_INSTANCE,
907 { selfHosted = true }: { selfHosted?: boolean } = {},
908 ): Promise<Admitted> {
909 const workspace = repo.namespace.toLowerCase();
910 const compute = gateFor(this.env);
911 const ent = await compute.entitlements(workspace);
912 if (ent?.paused) return { ok: false, code: "paused", message: refusalMessage("paused", workspace, kind, ent.paused) };
913 // Checks and the merge queue go to the workspace's own runners when it
914 // says so, and cost nothing there. Workflow jobs choose with `runs-on`.
915 const route = selfHosted && (kind === "check" || kind === "queue") ? await selfHostedRoute(this.env.ACTIONS, repo) : null;
916 if (route) return { ok: true, held: null, limits: limitsOf(ent), route };
917 const [standardMicros, isPublic] = await Promise.all([compute.microsPerSecond(), this.isPublic(repo)]);
918 // Reserved for its time limit at the larger of what it may cost (the
919 // sandbox price, scaled to its machine) and its minutes at the
920 // machine's rate, which is what it is charged.
921 const limit = estimateMinutes(minutes, ent);
922 const microsPerSecond = standardMicros * priceScale(instance);
923 const estimateMicros = Math.max(
924 sandboxEstimateMicros(limit, microsPerSecond),
925 runnerMinutes(limit * 60) * instance.microsPerMinute,
926 );
927 const admission = await compute.admit({ workspace, repo, public: isPublic, kind, estimateMicros }, ent);
928 if (!admission.ok) return { ok: false, code: admission.code, message: admission.message };
929 return {
930 ok: true,
931 held: admission.reservation ? { id: admission.reservation.id, workspace, microsPerSecond } : null,
932 limits: limitsOf(ent),
933 route: null,
934 };
935 }
936
937 /** Gives back what was reserved for a start that never reached its sandbox. */
938 private async release(held: Held | null): Promise<void> {
939 if (held) await gateFor(this.env).settle(held.id, 0);
940 }
941
942 /**
943 * Runs `start`, giving back what was reserved if it fails. A sandbox that
944 * could not start has given it back already; settling twice at nothing
945 * is harmless.
946 */
947 private async holding<T>(granted: Granted, start: () => Promise<T>): Promise<T> {
948 try {
949 return await start();
950 } catch (error) {
951 await this.release(granted.held);
952 throw error;
953 }
954 }
955
956 /**
957 * Starts one job of a GitHub Actions workflow in a sandbox of its own.
958 * The sandbox fetches the job, its contexts and its secrets with the
959 * job's token, and reports back to the actions service through the API.
960 * Jobs run on g1t's machines, so only for workspaces that may use them.
961 */
962 private async startActionsJob(args: ActionsJobArgs): Promise<Result<true>> {
963 // The machine its `runs-on` asked for; the standard one otherwise.
964 const instance = instanceNamed(args.instance);
965 // Workflow jobs run on g1t's machines: only as the workspace's plan
966 // allows, or on a public repository, from the open-source pool.
967 const admitted = await this.admitSandbox("workflow", args.repo, args.timeoutMinutes, instance);
968 if (!admitted.ok) return fail("payment_required", `Not started: ${admitted.message}`);
969 const namespace = this.jobNamespace(instance);
970 if (!namespace) {
971 await this.release(admitted.held);
972 return fail("invalid", `Not started: ${instance.label} machines are not available here.`);
973 }
974 const sandbox = namespace.get(namespace.idFromName(`actions:${args.job}`));
975 const on = instance === STANDARD_INSTANCE ? "" : ` on ${instance.label}`;
976 try {
977 await sandbox.run({
978 kind: "actions",
979 jobId: args.job,
980 token: args.token,
981 reservation: admitted.held,
982 limits: admitted.limits,
983 // The job's own time limit, and whether its requests pass the
984 // Worker for its brokered secrets.
985 build: {
986 kind: "actions",
987 minutes: Math.max(1, args.timeoutMinutes),
988 brokered: args.brokered === true,
989 },
990 meter: {
991 ...meter(args.repo, `A workflow job in ${args.repo.namespace}/${args.repo.name}${on}`),
992 instance: instance === STANDARD_INSTANCE ? null : instance.label,
993 },
994 envVars: {
995 MODE: "actions",
996 G1T_API: "https://api.g1t.sh",
997 ACTIONS_JOB: args.job,
998 ACTIONS_TOKEN: args.token,
999 // Docker of the job's own, inside its sandbox (crates/runner docker/).
1000 G1T_DOCKER: dockerFor(this.env.DOCKER),
1001 },
1002 });
1003 } catch (error) {
1004 // A sandbox that could not start, or stopped at once: the job fails
1005 // with why, rather than waiting to be noticed.
1006 return {
1007 ok: false,
1008 error: { code: "conflict", message: `The runner could not start the job: ${String(error).replace(/^Error: /, "")}` },
1009 };
1010 }
1011 // `true`, not null: an outcome needs a value.
1012 return ok(true);
1013 }
1014
1015 /** The sandboxes of a label: the class the catalogue names for it, by its binding. */
1016 private jobNamespace(instance: InstanceType): DurableObjectNamespace<AttemptSandbox> | null {
1017 return jobNamespaceOf(this.env, instance) as DurableObjectNamespace<AttemptSandbox> | null;
1018 }
1019
1020 /**
1021 * Builds one commit in a sandbox of its own and deploys it to g1t.page.
1022 * Asked by the deployments service, which has already checked that the
1023 * workspace pays for Deployments; that plan is what
1024 * lets a build use g1t's machines.
1025 */
1026 private async startDeploy(job: DeployJob): Promise<Result<true>> {
1027 // To read the commit, which may be private, as whoever pushed it.
1028 const token = await runCredential(this.env.IDENTITY, {
1029 onBehalfOf: job.actor,
1030 repo: job.source,
1031 kind: "deploy",
1032 use: "runner",
1033 read: [job.source],
1034 ttlSeconds: DEPLOY_TOKEN_TTL_SECONDS,
1035 });
1036 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(`deploy:${job.deployId}`));
1037 const workspace = (job.workspace ?? job.source.namespace).toLowerCase();
1038 // The project the build is for, and who it is charged to.
1039 const project = job.repo ?? job.source;
1040 try {
1041 await sandbox.run({
1042 kind: "deploy",
1043 deployId: job.deployId,
1044 token: job.token,
1045 reservation: job.reservation
1046 ? { id: job.reservation, workspace, microsPerSecond: job.microsPerSecond ?? 0 }
1047 : null,
1048 limits: { minutes: job.maxRunMinutes ?? null },
1049 // For as long as its read token lasts.
1050 build: { kind: "deploy", minutes: DEPLOY_TOKEN_TTL_SECONDS / 60 },
1051 owner: { workspace, repo: `${project.namespace}/${project.name}` },
1052 envVars: {
1053 MODE: "deploy",
1054 G1T_API: "https://api.g1t.sh",
1055 DEPLOY_ID: job.deployId,
1056 DEPLOY_TOKEN: job.token,
1057 G1T_USER: job.actor.username,
1058 G1T_TOKEN: token,
1059 GIT_REMOTE: `https://g1t.sh/${job.source.namespace}/${job.source.name}.git`,
1060 GIT_COMMIT: job.commit,
1061 ROOT_DIR: job.rootDir ?? "",
1062 BUILD_COMMAND: job.buildCommand ?? "",
1063 OUTPUT_DIR: job.outputDir ?? "",
1064 BUILD_AS: job.buildAs ?? "auto",
1065 DOCKERFILE: job.dockerfile ?? "",
1066 // A container build's own Docker Engine (crates/runner docker/),
1067 // started only if the project is built as one.
1068 G1T_DOCKER: dockerFor(this.env.DOCKER),
1069 BUILD_ENV: JSON.stringify(job.buildEnv ?? {}),
1070 BUILD_SECRETS: JSON.stringify(job.buildSecrets ?? {}),
1071 },
1072 });
1073 } catch (error) {
1074 return {
1075 ok: false,
1076 error: { code: "conflict", message: `The runner could not start the build: ${String(error).replace(/^Error: /, "")}` },
1077 };
1078 }
1079 return ok(true);
1080 }
1081
1082 /**
1083 * Makes a security update in a sandbox of its own (crates/runner
1084 * bump.rs): raises one package to a fixed version in the lockfiles
1085 * named, commits that as g1t and pushes it to its `g1t/security/…`
1086 * branch. A version update (`kind: "version"`) raises one or more
1087 * packages the same way, to the branch its dependency update file names,
1088 * which is never the default one. Asked by the security service, which
1089 * opens the pull request when it hears the push; nothing here opens one.
1090 * Admitted, reserved and metered like checks, always in g1t's sandbox (a
1091 * self-hosted runner may not know the mode), under the project's network
1092 * list plus the package registries. Returns whether the sandbox started.
1093 */
1094 private async startBump(input: unknown): Promise<Result<boolean>> {
1095 const problem = bumpProblem(input, UPDATE_BRANCH_PREFIX);
1096 if (problem) return fail("invalid", problem);
1097 const args = input as BumpArgs;
1098 const repo = args.repo;
1099 const what = args.kind === "version" ? "version update" : "security update";
1100 const actor = systemActor(repo.namespace);
1101 const closed = await this.closedRepo(actor, repo);
1102 if (closed) return closed;
1103 const admitted = await this.admitSandbox("check", repo, BUMP_MINUTES, STANDARD_INSTANCE, { selfHosted: false });
1104 if (!admitted.ok) return notAdmitted(admitted);
1105 try {
1106 const defaultBranch = await this.defaultBranch(repo, actor);
1107 // From its `target-branch`, which its pull request merges into, or
1108 // the default branch.
1109 const base = args.base ?? defaultBranch;
1110 // A version update names its own branch, which is never the one it
1111 // starts from, nor the default one.
1112 if (args.kind === "version" && (args.branch === defaultBranch || args.branch === base)) {
1113 await this.release(admitted.held);
1114 return fail("invalid", `A version update cannot push to ${args.branch}, the branch it starts from.`);
1115 }
1116 // As g1t, for the workspace: reads the repository and pushes this
1117 // branch only, with no API operations.
1118 const token = await runCredential(this.env.IDENTITY, {
1119 onBehalfOf: actor,
1120 repo,
1121 kind: "bump",
1122 use: "runner",
1123 read: [repo],
1124 push: [{ repo, branch: args.branch }],
1125 ttlSeconds: BUMP_TOKEN_TTL_SECONDS,
1126 });
1127 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(bumpSandboxName(args)));
1128 await sandbox.run({
1129 kind: "bump",
1130 repo,
1131 branch: args.branch,
1132 reservation: admitted.held,
1133 limits: admitted.limits,
1134 build: { kind: "bump", minutes: BUMP_MINUTES },
1135 meter: meter(repo, `${args.kind === "version" ? "Version" : "Security"} update in ${repo.namespace}/${repo.name}`),
1136 envVars: bumpEnv(args, base, token),
1137 });
1138 } catch (error) {
1139 await this.release(admitted.held);
1140 return fail("conflict", `The runner could not start the ${what}: ${String(error).replace(/^Error: /, "")}`);
1141 }
1142 return ok(true);
1143 }
1144
1145 /**
1146 * Events from the bus. Each one that could change what a pull request
1147 * needs next moves it along: the work service decides whether it can
1148 * merge by the repository's settings, and merges it if so. Merge checks
1149 * and the merge queue's states start here too.
1150 */
1151 async queue(batch: MessageBatch<G1tEvent>): Promise<void> {
1152 for (const message of batch.messages) {
1153 const event = message.body;
1154 switch (event.type) {
1155 case "pull.opened":
1156 case "pull.ready":
1157 case "pull.updated":
1158 case "checks.completed":
1159 case "review.completed":
1160 case "pull.mergeability":
1161 await this.advance(event.data.pullId);
1162 break;
1163 // Its head or its target moved and both changed the same files:
1164 // find out whether it still merges cleanly.
1165 case "pull.mergecheck":
1166 await this.startMergecheck(event.data.pullId);
1167 break;
1168 // Something joined, left or landed: test the next batch if none is.
1169 case "queue.changed":
1170 await this.buildQueue(event.data.repoId);
1171 break;
1172 // A person approved or asked for changes, which may let it merge.
1173 case "comment.created":
1174 if (event.data.pullId && event.data.verdict) await this.advance(event.data.pullId);
1175 break;
1176 // The branch the others would land on has moved.
1177 case "pull.merged":
1178 await this.advanceAll(event.data.repoId);
1179 break;
1180 // Read-only or gone: the runs there stop.
1181 case "repo.archived":
1182 case "repo.deleted":
1183 await this.stopRunsIn(event.data.repoId);
1184 break;
1185 }
1186 message.ack();
1187 }
1188 }
1189
1190 /** A sweep, for steps whose trigger was missed or whose sandbox died, and the nightly backups. */
1191 async scheduled(): Promise<void> {
1192 await this.advanceAll();
1193 await this.startBackups().catch((error: unknown) => console.log("backups not started", String(error)));
1194 }
1195
1196 /**
1197 * Starts a few of the nightly backups the repos service queued, each in
1198 * a sandbox of its own that holds only its job's token: the sandbox asks
1199 * for a read-only git credential itself, when it is ready to clone. No
1200 * plan is asked and nothing is metered: backups are g1t's own work.
1201 */
1202 private async startBackups(): Promise<void> {
1203 const pace = backupPace(this.env.BACKUPS_PER_SWEEP, this.env.BACKUPS_RUNNING);
1204 if (pace.perSweep === 0) return;
1205 const repos = reposClient(this.env.REPOS);
1206 for (const claim of await repos.claimBackups(pace.perSweep, pace.running)) {
1207 try {
1208 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(backupSandboxName(claim)));
1209 await sandbox.run({
1210 kind: "backup",
1211 jobId: claim.jobId,
1212 token: claim.token,
1213 // For `abuse.flagged`: whose repository it was.
1214 owner: { workspace: claim.path.namespace, repo: `${claim.path.namespace}/${claim.path.name}` },
1215 envVars: backupEnv(claim, "https://api.g1t.sh"),
1216 });
1217 } catch (error) {
1218 await repos.failBackup(claim.jobId, claim.token, `The sandbox could not start: ${String(error)}`).catch(() => null);
1219 }
1220 }
1221 }
1222
1223 private async advanceAll(repoId?: string): Promise<void> {
1224 const pulls = await workClient(this.env.WORK).managedPulls(repoId);
1225 for (const pullId of pulls) await this.advance(pullId);
1226 }
1227
1228 /**
1229 * Merges a pull request by the repository's settings when it is ready:
1230 * the work service decides and merges, and claims the step, so calling
1231 * this twice merges nothing twice.
1232 */
1233 private async advance(pullId: string): Promise<void> {
1234 const next = await workClient(this.env.WORK).advance(pullId);
1235 // g1t no longer reviews, revises or catches up pull requests itself.
1236 if (next.action !== "none") console.log("lifecycle step not taken", pullId, next.action);
1237 }
1238
1239 /**
1240 * Starts the next batch of a repository's merge queue, if it has one
1241 * ready: a sandbox per entry, all at once, each building the default
1242 * branch with that entry and everything ahead of it.
1243 */
1244 private async buildQueue(repoId: string): Promise<void> {
1245 const work = workClient(this.env.WORK);
1246 const jobs = await work.queueBuild(repoId);
1247 // Merge queue sandboxes, like any other, only as the workspace's plan
1248 // allows: refused states fail at once, saying why. A state whose
1249 // sandbox could not start fails at once too, rather than holding the
1250 // queue until it times out.
1251 await Promise.all(
1252 jobs.map(async (job) => {
1253 const admitted = await this.admitSandbox("queue", job.repo, DEFAULT_MINUTES.queue);
1254 if (!admitted.ok) {
1255 await work.failQueue(job.entryId, job.token, `Not started: ${admitted.message}`);
1256 return;
1257 }
1258 await this.holding(admitted, () => this.startQueueRun(job, admitted)).catch((error: unknown) =>
1259 work.failQueue(job.entryId, job.token, `Its sandbox could not start: ${String(error)}`),
1260 );
1261 }),
1262 );
1263 }
1264
1265 private async startQueueRun(job: QueueJob, granted: Granted): Promise<void> {
1266 // To read the changes and push the tested state, as a member.
1267 // Reads each queued change; pushes only the queue's own branch.
1268 const token = await runCredential(this.env.IDENTITY, {
1269 onBehalfOf: job.actor,
1270 repo: job.repo,
1271 kind: "queue",
1272 use: "runner",
1273 number: job.stack.at(-1)?.number ?? null,
1274 read: job.stack.map((item) => item.source),
1275 push: [{ repo: job.repo, branch: job.branch }],
1276 ttlSeconds: CHECKS_TOKEN_TTL_SECONDS,
1277 });
1278 const remote = (path: RepoPath) => `https://g1t.sh/${path.namespace}/${path.name}.git`;
1279 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(`queue-${job.entryId}-${job.baseCommit}`));
1280 await sandbox.run({
1281 kind: "queue",
1282 entryId: job.entryId,
1283 token: job.token,
1284 reservation: granted.held,
1285 limits: granted.limits,
1286 selfHosted: granted.route,
1287 track: {
1288 actor: job.actor,
1289 repo: job.repo,
1290 kind: "queue",
1291 number: job.stack.at(-1)?.number ?? null,
1292 title: `Merge queue: ${job.stack.map((item) => `#${item.number}`).join(" + ")}`,
1293 },
1294 meter: meter(job.repo, `Merge queue on ${job.repo.namespace}/${job.repo.name}`),
1295 envVars: {
1296 MODE: "queue",
1297 G1T_API: "https://api.g1t.sh",
1298 QUEUE_ENTRY: job.entryId,
1299 QUEUE_TOKEN: job.token,
1300 G1T_USER: job.actor.username,
1301 G1T_TOKEN: token,
1302 BASE_REMOTE: remote(job.repo),
1303 BASE_COMMIT: job.baseCommit,
1304 QUEUE_BRANCH: job.branch,
1305 STACK: JSON.stringify(
1306 job.stack.map((item) => ({
1307 number: item.number,
1308 title: item.title,
1309 remote: remote(item.source),
1310 branch: item.branch,
1311 commit: item.commit,
1312 })),
1313 ),
1314 CHECKS: JSON.stringify(job.checks),
1315 CONTRACT_CHECKS: JSON.stringify(job.contractChecks),
1316 },
1317 });
1318 }
1319
1320 /**
1321 * Merges a pull request's head into its target in a sandbox of its own,
1322 * pushing nothing, to find the files that conflict.
1323 * The work service decides when one is needed and how many may run.
1324 */
1325 private async startMergecheck(pullId: string): Promise<void> {
1326 const work = workClient(this.env.WORK);
1327 const started = await work.startMergecheck(pullId);
1328 if (!started.ok) return;
1329 const job = started.value;
1330 let granted: Granted | null = null;
1331 try {
1332 // Like any sandbox, only as the workspace's plan allows.
1333 const admitted = await this.admitSandbox("check", job.repo, DEFAULT_MINUTES.mergecheck);
1334 if (!admitted.ok) throw new Error(`Not started: ${admitted.message}`);
1335 granted = admitted;
1336 // To read the change, which may be private, as whoever opened it.
1337 const token = await runCredential(this.env.IDENTITY, {
1338 onBehalfOf: job.author,
1339 repo: job.repo,
1340 kind: "mergecheck",
1341 use: "runner",
1342 number: job.number,
1343 read: [job.repo, job.source],
1344 ttlSeconds: MERGECHECK_TOKEN_TTL_SECONDS,
1345 });
1346 const remote = (path: RepoPath) => `https://g1t.sh/${path.namespace}/${path.name}.git`;
1347 // One sandbox per pair of commits: asking twice starts nothing twice.
1348 const sandbox = this.env.SANDBOX.get(
1349 this.env.SANDBOX.idFromName(`mergecheck-${job.pullId}-${job.head}-${job.base}`),
1350 );
1351 await sandbox.run({
1352 kind: "mergecheck",
1353 pullId: job.pullId,
1354 token: job.token,
1355 reservation: granted.held,
1356 limits: granted.limits,
1357 selfHosted: granted.route,
1358 meter: meter(job.repo, `Merge check of ${job.repo.namespace}/${job.repo.name}#${job.number}`),
1359 envVars: {
1360 MODE: "mergecheck",
1361 G1T_API: "https://api.g1t.sh",
1362 MERGECHECK_PULL: job.pullId,
1363 MERGECHECK_TOKEN: job.token,
1364 G1T_USER: job.author.username,
1365 G1T_TOKEN: token,
1366 BASE_REMOTE: remote(job.repo),
1367 BASE_COMMIT: job.base,
1368 HEAD_REMOTE: remote(job.source),
1369 HEAD_BRANCH: job.branch,
1370 HEAD_COMMIT: job.head,
1371 },
1372 });
1373 } catch (error) {
1374 if (granted) await this.release(granted.held);
1375 await work.failMergecheck(job.pullId, job.token, error instanceof Error ? error.message : String(error));
1376 }
1377 }
1378
1379 /**
1380 * A refusal if `repo` takes no work from anyone: it is archived, so
1381 * read-only, or it was deleted (repos hides a deleted one, so it is not
1382 * found). Null when repos cannot answer now; the other checks still run.
1383 */
1384 private async closedRepo(actor: User, repo: RepoPath): Promise<Result<never> | null> {
1385 const repos = reposClient(this.env.REPOS);
1386 const found = await repos.get(repo, actor).catch(() => null);
1387 if (!found) return null;
1388 if (!found.ok) {
1389 return found.error.code === "not_found"
1390 ? fail("not_found", `There is no repository at ${repo.namespace}/${repo.name}, or it was deleted.`)
1391 : null;
1392 }
1393 const status = await repos.statusById(found.value.id).catch(() => null);
1394 if (status?.deleted) {
1395 return fail("not_found", `${found.value.namespace}/${found.value.name} was deleted. An owner can restore it from the workspace's settings.`);
1396 }
1397 if (status?.archived || found.value.archivedAt) {
1398 return fail(
1399 "forbidden",
1400 `${found.value.namespace}/${found.value.name} is archived, so it is read-only. An owner can unarchive it in its settings.`,
1401 );
1402 }
1403 return null;
1404 }
1405
1406 /**
1407 * Stops every run in a repository that was archived or deleted: the
1408 * work service marks them stopped when it hears of it, and lists them
1409 * here (`runs_in_repo`, by id, so a deleted repository's runs are found
1410 * too), and each sandbox is destroyed. Never throws.
1411 */
1412 private async stopRunsIn(repoId: string): Promise<void> {
1413 try {
1414 const response = await this.env.WORK.fetch("https://work/rpc/runs_in_repo", {
1415 method: "POST",
1416 headers: { "content-type": "application/json" },
1417 body: JSON.stringify({ repoId }),
1418 });
1419 if (!response.ok) return;
1420 const runs = (await response.json()) as { runId: string; sandbox: string | null }[];
1421 for (const run of runs) {
1422 if (!run.sandbox) continue;
1423 try {
1424 await this.env.SANDBOX.get(this.env.SANDBOX.idFromString(run.sandbox)).halt("The repository was archived or deleted.");
1425 } catch (error) {
1426 // Already gone, or never started.
1427 console.log("sandbox not destroyed", run.runId, String(error));
1428 }
1429 }
1430 } catch (error) {
1431 console.error("could not stop the runs in", repoId, error);
1432 }
1433 }
1434
1435 private async defaultBranch(repo: RepoPath, viewer: Viewer): Promise<string> {
1436 const found = await reposClient(this.env.REPOS).get(repo, viewer);
1437 return found.ok ? found.value.defaultBranch : "main";
1438 }
1439}