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

Commit

A g1t Actions job can now open a g1t.page build of its repository's project and build it in its own checkout: the deployments service's start_from_workflow takes the job's own token, which needs deployments: write and reaches only that repository, looks the run up with the actions service while the job is still running, and builds production for a run on the project's production branch, a pull request's preview for a pull request's run (pr-<n> for one from a fork), or a branch's preview for a run on any other branch. A pull request from a fork or from someone who cannot push, and a run that had to be approved, get no build secrets. The plan and the spend limit are checked as for any build, the commit's g1t / deploy status and the build's row are set as they are now, and nothing is reserved or charged for the build's seconds, which the job already pays for as Actions time; the build reports with its own token as a sandbox's does. Opening a build is shared with the sandbox path, which now only adds the reservation and the runner's start.

Claude-Session:
https://claude.ai/code/session_01PnwTRSNaC7vEHTAuBzjhrn
Claudeagent and Claudeagentcommitted Parent84c72b3Browse files
5 files+732−620/5 viewed
+6−0
1+-- A build a g1t Actions job runs in its own checkout (`start_from_workflow`,
2+-- `g1t-runner deploy`): the run and job that opened it. The job pays for
3+-- its own time, so such a build's seconds are not charged again here.
4+-- Null for builds g1t's sandboxes ran.
5+ALTER TABLE deployments ADD COLUMN run_id TEXT;
6+ALTER TABLE deployments ADD COLUMN job_id TEXT;
+225−62
1212 * for Platforms namespace, where the `*.g1t.page` dispatcher finds it by
1313 * hostname.
1414 *
15+ * A g1t Actions job can open a build too (`startFromWorkflow`, for
16+ * `POST /repos/{owner}/{repo}/deployments/builds` and the `g1t/deploy`
17+ * action) and build it in its own checkout with `g1t-runner deploy`; it
18+ * reports the same way, and its time is the job's, not a sandbox's.
19+ *
1520 * Nothing here is free, and nothing is capped. On the plan, every build
1621 * second is charged as it happens; requests, CPU time and custom domains
1722 * are charged from the first once the month is over, all at cost plus the
3237 DEPLOYMENT_COSTS,
3338 SLUG_HOLD_DAYS,
3439 ComputeGate,
40+ actionsClient,
3541 allows,
3642 billingClient,
3743 can,
8591 import { commitMissing, MAX_IDENTICAL_FAILURES, missingCommitMessage, retryDecision, type PastBuild } from "./retries";
8692 import { appHost, appUrl, label, uniqueLabel } from "./names";
8793 import { RepoDeployments, sourceOf } from "./repo-deployments";
94+import { commitOf, jobOf, projectNamed, runTrusted, targetOf, type WorkflowBuild } from "./workflow-builds";
8895
8996 type Env = {
9097 DB: D1Database;
219226 created_by: string;
220227 created_at: string;
221228 finished_at: string | null;
229+ /** Set for a build a g1t Actions job runs (migration 0011): the job pays for its time. */
230+ run_id?: string | null;
231+ job_id?: string | null;
232+};
233+
234+/** What opening any build takes; see `open`. */
235+type OpenInput = {
236+ project: Project;
237+ kind: DeployKind;
238+ branch: string | null;
239+ number: number | null;
240+ commit: string;
241+ createdBy: string;
242+ settings: SettingsRow;
243+ /** A push, or work by a member or an agent; see `insider`. */
244+ trusted: boolean;
245+ /** The g1t Actions job that builds it, for a build opened from a workflow. */
246+ workflow?: { runId: string; jobId: string };
222247 };
223248
249+/** Why a build was not opened: the plan or the limit, or g1t itself. */
250+type Refusal = "payment_required" | "conflict";
251+
252+/** A sandbox's reservation with billing; none for a workflow job's build. */
253+type Compute = { reservation: string | null; microsPerSecond: number; maxRunMinutes: number | null };
254+
255+type Opened =
256+ | { refused: string; code: Refusal; id: string }
257+ | {
258+ refused: null;
259+ id: string;
260+ token: string;
261+ script: string;
262+ build: { secrets: Record<string, string>; variables: Record<string, string> };
263+ compute: Compute;
264+ };
265+
224266 type AppRow = {
225267 script: string;
226268 project_id: string;
689731 // ---- Starting builds -----------------------------------------------
690732
691733 /**
692− * Opens a deployment and starts its build. Skipped, with the reason
693− * recorded, when the workspace's plan is off.
734+ * Opens a deployment and starts its build in a sandbox. Skipped, with the
735+ * reason recorded, when the workspace's plan is off.
694736 */
695− private async start(input: {
696− project: Project;
697− kind: DeployKind;
698− branch: string | null;
699− number: number | null;
700− commit: string;
737+ private async start(input: OpenInput & {
738+ /** Where the build reads the commit from: the project, or a pull request's fork. */
701739 source: RepoPath;
740+ /** Who reads it: the workspace, or whoever the pull request is for. */
702741 reader: User;
703− createdBy: string;
704− settings: SettingsRow;
705− /** A push, or work by a member or an agent; see `insider`. */
706− trusted: boolean;
707742 }): Promise<Result<Deployment>> {
708743 const { project } = input;
709744 const repo = repoOf(project);
745+ const opened = await this.open(input, { reserve: true });
746+ if (opened.refused !== null) return ok(toDeployment((await this.deploymentRow(opened.id))!));
747+ const { build, compute } = opened;
748+ const response = await this.env.RUNNER.fetch("https://runner/rpc/start_deploy", {
749+ method: "POST",
750+ headers: { "content-type": "application/json" },
751+ body: JSON.stringify({
752+ deployId: opened.id,
753+ token: opened.token,
754+ workspace: project.workspace,
755+ reservation: compute.reservation,
756+ microsPerSecond: compute.microsPerSecond,
757+ maxRunMinutes: compute.maxRunMinutes,
758+ actor: input.reader,
759+ source: input.source,
760+ // The project, whose guardrails the build runs under: a preview's
761+ // source is its pull request's working copy, not the project.
762+ repo: repo.path,
763+ repoId: repo.id,
764+ commit: input.commit,
765+ rootDir: project.source.rootDir,
766+ buildCommand: input.settings.build_command,
767+ outputDir: input.settings.output_dir,
768+ buildEnv: build.variables,
769+ buildSecrets: build.secrets,
770+ }),
771+ });
772+ const started = response.ok ? ((await response.json()) as Result<true>) : fail("conflict", `The runner answered ${response.status}.`);
773+ if (!started.ok) {
774+ // Never reached a sandbox: what was reserved is given back.
775+ if (compute.reservation) await gateFor(this.env.BILLING).settle(compute.reservation, 0);
776+ await this.finishFailed(opened.id, started.error.message, null, null);
777+ }
778+ return ok(toDeployment((await this.deploymentRow(opened.id))!));
779+ }
780+
781+ /**
782+ * What every build does before anything builds it: checks the plan and
783+ * the spend limit (and, for a sandbox, reserves its compute with
784+ * billing), records the deployment with a token for that build alone,
785+ * replaces older builds of the same app, sets the commit's status, and
786+ * reads what the project's secrets and variables give builds of its
787+ * kind. A refusal is the deployment's status, saying what to do; nothing
788+ * is resolved for a refused build.
789+ */
790+ private async open(input: OpenInput, options: { reserve: boolean }): Promise<Opened> {
791+ const { project } = input;
792+ const repo = repoOf(project);
710793 const script = await this.scriptFor(project, input.branch);
711794 const id = newId("dpl");
712795 const token = randomToken();
713796 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
714797 const limit = await billingClient(this.env.BILLING).checkLimit(project.workspace);
798+ const stopped = limit.ok && limit.value.state === "stopped";
715799 const cloudflare = this.cloudflare;
716800 let refused = !plan.ok
717801 ? plan.error.message
718− : limit.ok && limit.value.state === "stopped"
802+ : stopped
719803 ? (limit.value.message ?? "The workspace reached its usage limit.")
720804 : !cloudflare
721805 ? "Deployments are not set up on this g1t: it has no Cloudflare token."
722806 : null;
723− // A build is compute: reserved with billing before it starts, and
724− // settled by its sandbox when it stops. A refusal is the deployment's
725− // status, saying what to do.
726− const compute = gateFor(this.env.BILLING);
727− let reservation: string | null = null;
728− let microsPerSecond = 0;
729− let maxRunMinutes: number | null = null;
730− if (!refused) {
731− const ent = await compute.entitlements(project.workspace);
732− microsPerSecond = await compute.microsPerSecond();
733− maxRunMinutes = ent && ent.maxRunMinutes > 0 ? ent.maxRunMinutes : null;
807+ let code: Refusal = plan.ok && !stopped ? "conflict" : "payment_required";
808+ // A sandbox's build is compute: reserved with billing before it starts,
809+ // and settled by its sandbox when it stops. A workflow job's build runs
810+ // in the job, which pays for its own time.
811+ const compute: Compute = { reservation: null, microsPerSecond: 0, maxRunMinutes: null };
812+ if (!refused && options.reserve) {
813+ const gate = gateFor(this.env.BILLING);
814+ const ent = await gate.entitlements(project.workspace);
815+ compute.microsPerSecond = await gate.microsPerSecond();
816+ compute.maxRunMinutes = ent && ent.maxRunMinutes > 0 ? ent.maxRunMinutes : null;
734817 const isPrivate = await reposClient(this.env.REPOS)
735818 .get(repo.path, null)
736819 .then((found) => !found.ok || found.value.isPrivate)
737820 .catch(() => true);
738− const admitted = await compute.admit(
821+ const admitted = await gate.admit(
739822 {
740823 workspace: project.workspace,
741824 repo: repo.path,
742825 public: !isPrivate,
743826 kind: "deploy",
744− estimateMicros: sandboxEstimateMicros(Math.min(BUILD_MINUTES, maxRunMinutes ?? BUILD_MINUTES), microsPerSecond),
827+ estimateMicros: sandboxEstimateMicros(Math.min(BUILD_MINUTES, compute.maxRunMinutes ?? BUILD_MINUTES), compute.microsPerSecond),
745828 },
746829 ent,
747830 );
748− if (admitted.ok) reservation = admitted.reservation?.id ?? null;
749− else refused = admitted.message;
831+ if (admitted.ok) compute.reservation = admitted.reservation?.id ?? null;
832+ else {
833+ refused = admitted.message;
834+ code = "payment_required";
835+ }
750836 }
751837 await this.db
752838 .prepare(
753839 `INSERT INTO deployments (id, project_id, workspace, slug, repo_id, repo, kind, branch, number, commit_sha,
754− script, status, error, token_hash, trusted, created_by, created_at, finished_at)
755− VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
840+ script, status, error, token_hash, trusted, created_by, created_at, finished_at, run_id, job_id)
841+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
756842 )
757843 .bind(
758844 id,
773859 input.createdBy,
774860 now(),
775861 refused ? now() : null,
862+ input.workflow?.runId ?? null,
863+ input.workflow?.jobId ?? null,
776864 )
777865 .run();
778− if (refused) return ok(toDeployment((await this.deploymentRow(id))!));
866+ if (refused) return { refused, code, id };
779867 // Older builds of the same app are replaced by this one.
780868 await this.db
781869 .prepare(
792880 input.kind,
793881 input.trusted,
794882 );
795− const response = await this.env.RUNNER.fetch("https://runner/rpc/start_deploy", {
796− method: "POST",
797− headers: { "content-type": "application/json" },
798− body: JSON.stringify({
799− deployId: id,
800− token,
801− workspace: project.workspace,
802− reservation,
803− microsPerSecond,
804− maxRunMinutes,
805− actor: input.reader,
806− source: input.source,
807− // The project, whose guardrails the build runs under: a preview's
808− // source is its pull request's working copy, not the project.
809− repo: repo.path,
810− repoId: repo.id,
811− commit: input.commit,
812− rootDir: project.source.rootDir,
813− buildCommand: input.settings.build_command,
814− outputDir: input.settings.output_dir,
815− buildEnv: build.variables,
816− buildSecrets: build.secrets,
817− }),
818− });
819− const started = response.ok ? ((await response.json()) as Result<true>) : fail("conflict", `The runner answered ${response.status}.`);
820− if (!started.ok) {
821− // Never reached a sandbox: what was reserved is given back.
822− if (reservation) await compute.settle(reservation, 0);
823− await this.finishFailed(id, started.error.message, null, null);
883+ return { refused: null, id, token, script, build, compute };
884+ }
885+
886+ /**
887+ * For a g1t Actions job (`POST /repos/{owner}/{repo}/deployments/builds`):
888+ * opens a g1t.page build of one of the repository's projects, which the
889+ * job builds in its own checkout with `g1t-runner deploy`. The caller is
890+ * the job's own token (`token.job`), which needs `deployments: write` and
891+ * reaches this repository only. The run decides what is built: a run on
892+ * the project's production branch builds production, a pull request's
893+ * run its preview, a run on another branch that branch's preview. A pull
894+ * request from a fork or from someone who cannot push here, and a run
895+ * that needed approval, get no build secrets. The job pays for its own
896+ * time, so nothing is reserved or charged here for the build; the plan
897+ * and the spend limit are checked as for any build.
898+ */
899+ async startFromWorkflow(a: { actor: User; repo: RepoPath; project?: string | null; sha?: string | null }): Promise<Result<WorkflowBuild>> {
900+ const repoPath = { namespace: String(a.repo?.namespace ?? ""), name: String(a.repo?.name ?? "") };
901+ const job = jobOf(a.actor, repoPath);
902+ if ("error" in job) return fail("forbidden", job.error);
903+ const actor = await this.workspaceActor(repoPath.namespace);
904+ if (!actor) return fail("not_found", "There is no such repository.");
905+ const found = await reposClient(this.env.REPOS).get(repoPath, actor);
906+ if (!found.ok) return fail("not_found", "There is no such repository.");
907+ const projects = await this.projects.byRepo(found.value.id);
908+ const rows = new Map(await Promise.all(projects.map(async (p) => [p.id, await this.settingsRow(p.id)] as const)));
909+ const live = (row: SettingsRow | null | undefined): row is SettingsRow =>
910+ !!row?.enabled && !row.repo_deleted_at && !row.workspace_deleted_at;
911+ const chosen = projectNamed(projects, (p) => live(rows.get(p.id)), a.project);
912+ if ("error" in chosen) return fail(chosen.code, chosen.error);
913+ const project = chosen;
914+ if (project.source.kind !== "hosted") return fail("conflict", "Only projects hosted on g1t deploy so far.");
915+ if (!can(a.actor, repoRef(project), NEEDS.redeploy)) return fail("forbidden", needs(NEEDS.redeploy));
916+ const settings = rows.get(project.id);
917+ if (!live(settings)) return fail("conflict", `Deployments are off for ${project.slug}.`);
918+ const repo = repoOf(project);
919+
920+ // The run and its job, as the actions service has them: still running.
921+ const detail = await actionsClient(this.env.ACTIONS).run(repo.path, actor, job.runId);
922+ if (!detail.ok) return fail("not_found", "The job's run was not found.");
923+ const { run } = detail.value;
924+ const running = detail.value.jobs.find((j) => j.id === job.jobId);
925+ if (run.status === "completed" || !running || running.status === "completed") {
926+ return fail("conflict", "The job has finished: a build is opened from a job while it runs.");
824927 }
825− return ok(toDeployment((await this.deploymentRow(id))!));
928+ const commit = commitOf(a.sha, run);
929+ if (typeof commit !== "string") return fail("invalid", commit.error);
930+
931+ const target = targetOf(run, repo.defaultBranch);
932+ if ("error" in target) return fail("invalid", target.error);
933+ let kind: DeployKind = "production";
934+ let branch: string | null = null;
935+ let number: number | null = null;
936+ let trusted = runTrusted({ approval: detail.value.approval, pull: null, insider: true });
937+ if (target.kind === "pull") {
938+ const pull = await workClient(this.env.WORK).getPull(repo.path, target.number, actor);
939+ if (!pull.ok) return fail("not_found", `Pull request #${target.number} was not found.`);
940+ const open = pull.value.pull;
941+ if (open.status !== "open" && open.status !== "draft") {
942+ return fail("conflict", `Pull request #${target.number} is not open, so it has no preview.`);
943+ }
944+ kind = "preview";
945+ // A pull request from a fork has no branch here (see `deployPreview`).
946+ branch = open.branch ?? `pr-${target.number}`;
947+ number = target.number;
948+ trusted = runTrusted({
949+ approval: detail.value.approval,
950+ pull: open,
951+ insider: await this.insider(repo.path, workOwner(open), actor),
952+ });
953+ } else if (target.kind === "branch") {
954+ kind = "preview";
955+ branch = target.branch;
956+ }
957+ if (kind === "production" && !settings.production) return fail("conflict", `Production deploys are off for ${project.slug}.`);
958+ if (kind === "preview" && !settings.previews) return fail("conflict", `Previews are off for ${project.slug}.`);
959+
960+ const opened = await this.open(
961+ { project, kind, branch, number, commit, createdBy: run.actor ?? "g1t", settings, trusted, workflow: job },
962+ { reserve: false },
963+ );
964+ if (opened.refused !== null) return fail(opened.code, opened.refused);
965+ return ok({
966+ deploy_id: opened.id,
967+ token: opened.token,
968+ kind,
969+ project: project.slug,
970+ branch,
971+ number,
972+ commit,
973+ url: appUrl(opened.script),
974+ trusted,
975+ root_dir: project.source.rootDir ?? "",
976+ build_command: settings.build_command,
977+ output_dir: settings.output_dir,
978+ build_env: opened.build.variables,
979+ build_secrets: opened.build.secrets,
980+ });
826981 }
827982
828983 private async deployProduction(project: Project, commit: string | null, createdBy: string): Promise<Result<Deployment> | null> {
11281283 .catch((reason: unknown) => console.error("deployment not published", row.id, String(reason)));
11291284 }
11301285
1131− /** Each build is charged by the second, from the first, at the container price plus the margin. */
1286+ /**
1287+ * Each build is charged by the second, from the first, at the container
1288+ * price plus the margin. A build a workflow job ran is not: the job's
1289+ * time is charged as Actions time.
1290+ */
11321291 private async chargeBuild(row: DeploymentRow, seconds: number): Promise<void> {
1292+ if (row.run_id) return;
11331293 const costs = await this.costs();
11341294 const cost = Math.ceil(Math.ceil(seconds) * costs.buildSecond);
11351295 if (cost <= 0) return;
22132373 return service.get(args);
22142374 case "redeploy":
22152375 return service.redeploy(args);
2376+ // For the API: a g1t Actions job opening a build it runs itself.
2377+ case "start_from_workflow":
2378+ return service.startFromWorkflow(args);
22162379 case "take_down":
22172380 return service.takeDown(args);
22182381 case "overview":
+272−0
1+/**
2+ * The whole service, on Node, opening builds for g1t Actions jobs
3+ * (`start_from_workflow`): a D1 made of node:sqlite with the service's
4+ * migrations, the other services as fakes that answer by method, and
5+ * Cloudflare's API as a recorded fetch. See moves.flow.test.ts.
6+ */
7+
8+import assert from "node:assert/strict";
9+import { readdirSync, readFileSync } from "node:fs";
10+import { registerHooks } from "node:module";
11+import { DatabaseSync } from "node:sqlite";
12+import { beforeEach, test } from "node:test";
13+
14+registerHooks({
15+ resolve(specifier, context, next) {
16+ try {
17+ return next(specifier, context);
18+ } catch (error) {
19+ if (specifier.startsWith(".")) return next(`${specifier}.ts`, context);
20+ throw error;
21+ }
22+ },
23+});
24+const { default: worker } = await import("./index.ts");
25+
26+function d1(db: DatabaseSync) {
27+ const value = (v: unknown) => (v === undefined ? null : typeof v === "boolean" ? Number(v) : v);
28+ const statement = (sql: string, params: unknown[] = []) => ({
29+ sql,
30+ params,
31+ bind: (...args: unknown[]) => statement(sql, args.map(value)),
32+ async first<T>() {
33+ return (db.prepare(sql).get(...(params as never[])) as T) ?? null;
34+ },
35+ async all<T>() {
36+ return { results: db.prepare(sql).all(...(params as never[])) as T[], success: true };
37+ },
38+ async run() {
39+ const r = db.prepare(sql).run(...(params as never[]));
40+ return { success: true, meta: { changes: Number(r.changes) } };
41+ },
42+ });
43+ return {
44+ prepare: (sql: string) => statement(sql),
45+ async batch(statements: ReturnType<typeof statement>[]) {
46+ db.exec("BEGIN");
47+ try {
48+ const out = statements.map((s) => ({ results: db.prepare(s.sql).all(...(s.params as never[])) }));
49+ db.exec("COMMIT");
50+ return out;
51+ } catch (error) {
52+ db.exec("ROLLBACK");
53+ throw error;
54+ }
55+ },
56+ };
57+}
58+
59+const REPO = "rep_web";
60+const PROJECT = "prj_web";
61+const SHA = "4b8d0f2a6c1e3579bd02468ace13579bdf02468a";
62+
63+function world() {
64+ const sqlite = new DatabaseSync(":memory:");
65+ const dir = new URL("../migrations/", import.meta.url);
66+ for (const file of readdirSync(dir).sort()) sqlite.exec(readFileSync(new URL(file, dir), "utf8"));
67+ const state = {
68+ sqlite,
69+ run: { id: "run_1", event: "push", ref: "refs/heads/main", sha: SHA, pull: null as number | null, status: "in_progress", actor: "ada" },
70+ jobStatus: "in_progress",
71+ approval: null as null | { state: string },
72+ pull: { status: "open", headCommit: SHA, branch: "feature" as string | null, fork: null as null | { namespace: string; name: string } },
73+ collaborator: "write",
74+ plan: true,
75+ /** What other services were asked, as `SERVICE method`. */
76+ calls: [] as string[],
77+ /** What `resolve_settings` was asked for. */
78+ resolved: [] as { environment: string; trusted: boolean }[],
79+ };
80+ const project = () => ({
81+ id: PROJECT,
82+ workspace: "acme",
83+ slug: "web",
84+ name: "web",
85+ primary: true,
86+ source: { kind: "hosted", repoId: REPO, repo: { namespace: "acme", name: "web" }, defaultBranch: "main", rootDir: "site" },
87+ });
88+ const ok = (value: unknown) => ({ ok: true, value });
89+ const service = (name: string, answer: (method: string, args: any) => unknown) => ({
90+ async fetch(input: string | Request, init?: RequestInit) {
91+ const url = typeof input === "string" ? input : input.url;
92+ const method = new URL(url).pathname.split("/").pop()!;
93+ state.calls.push(`${name} ${method}`);
94+ const args = init?.body ? JSON.parse(String(init.body)) : {};
95+ return Response.json((await answer(method, args)) ?? ok(null));
96+ },
97+ });
98+ const env = {
99+ DB: d1(sqlite),
100+ CLOUDFLARE_API_TOKEN: "test",
101+ CLOUDFLARE_ACCOUNT_ID: "acct",
102+ DISPATCH_NAMESPACE: "g1t-deployments",
103+ SITE: "https://g1t.sh",
104+ REPOS: service("REPOS", (method) => {
105+ if (method === "get") return ok({ id: REPO, namespace: "acme", name: "web", isPrivate: true, defaultBranch: "main" });
106+ return ok([]);
107+ }),
108+ PROJECTS: service("PROJECTS", (method) => (method === "by_repo" ? [project()] : ok(project()))),
109+ IDENTITY: service("IDENTITY", (method, args) => {
110+ if (method === "get_workspace") return { id: `wsp_${args.slug}`, slug: args.slug };
111+ if (method === "collaborator_permission") return ok({ role: state.collaborator });
112+ return ok(null);
113+ }),
114+ BILLING: service("BILLING", (method) => {
115+ if (method === "has_feature") return state.plan ? ok(true) : { ok: false, error: { code: "payment_required", message: "Deployments need the Team plan." } };
116+ if (method === "check_limit") return ok({ state: "ok" });
117+ if (method === "prices") return { prices: [] };
118+ return ok(null);
119+ }),
120+ WORK: service("WORK", (method) => {
121+ if (method === "get_pull") return ok({ pull: { ...state.pull, number: 9, author: { username: "eve", kind: "user" } } });
122+ return ok(null);
123+ }),
124+ ACTIONS: service("ACTIONS", (method, args) => {
125+ if (method === "run") {
126+ return ok({ run: state.run, jobs: [{ id: "job_1", status: state.jobStatus }], notes: [], approval: state.approval });
127+ }
128+ if (method === "resolve_settings") {
129+ state.resolved.push({ environment: args.environment, trusted: args.trusted });
130+ return { secrets: { API_KEY: "s3cret-value" }, variables: { PUBLIC_URL: "https://example.com" } };
131+ }
132+ return ok(null);
133+ }),
134+ RUNNER: service("RUNNER", () => ok(true)),
135+ };
136+ return { state, env };
137+}
138+
139+let w: ReturnType<typeof world>;
140+const realFetch = globalThis.fetch;
141+
142+beforeEach(() => {
143+ w = world();
144+ globalThis.fetch = (async (input: string | Request) => {
145+ const url = new URL(typeof input === "string" ? input : input.url);
146+ w.state.calls.push(`CLOUDFLARE ${url.pathname}`);
147+ if (url.pathname.endsWith("/graphql")) return Response.json({ data: { viewer: { accounts: [{}] } } });
148+ return Response.json({ success: true, result: { jwt: "done", buckets: [] }, result_info: { total_pages: 1 } });
149+ }) as typeof fetch;
150+ w.state.sqlite
151+ .prepare(
152+ `INSERT INTO settings (project_id, repo_id, workspace, slug, enabled, previews, production, idle_days, updated_at)
153+ VALUES (?, ?, 'acme', 'web', 1, 1, 1, 7, '2026-10-05T00:00:00Z')`,
154+ )
155+ .run(PROJECT, REPO);
156+});
157+
158+process.on("exit", () => {
159+ globalThis.fetch = realFetch;
160+});
161+
162+const jobActor = (scopes: string[] = ["contents:read", "deployments:write"]) => ({
163+ id: "wsp_acme",
164+ username: "acme",
165+ kind: "workspace",
166+ verified: true,
167+ workspaces: [{ slug: "acme", role: "member" }],
168+ token: { token_id: "tok_job", repo: "acme/web", job: { run_id: "run_1", job_id: "job_1" }, scopes },
169+});
170+
171+async function post(path: string, body: unknown): Promise<any> {
172+ const response = await worker.fetch(
173+ new Request(`https://deployments${path}`, { method: "POST", body: JSON.stringify(body) }),
174+ w.env as never,
175+ {} as never,
176+ );
177+ return response.json();
178+}
179+
180+const open = (body: Record<string, unknown> = {}) =>
181+ post("/rpc/start_from_workflow", { actor: jobActor(), repo: { namespace: "acme", name: "web" }, ...body });
182+
183+const rows = (sql: string, ...params: unknown[]) =>
184+ (w.state.sqlite.prepare(sql).all(...(params as never[])) as any[]).map((row) => ({ ...row }));
185+
186+test("a push to the production branch opens a production build the job runs, with no sandbox and nothing reserved", async () => {
187+ const answer = await open();
188+ assert.equal(answer.ok, true, JSON.stringify(answer));
189+ const build = answer.value;
190+ assert.equal(build.kind, "production");
191+ assert.equal(build.project, "web");
192+ assert.equal(build.commit, SHA);
193+ assert.equal(build.url, "https://web-acme.g1t.page");
194+ assert.equal(build.trusted, true);
195+ assert.equal(build.root_dir, "site");
196+ assert.deepEqual(build.build_secrets, { API_KEY: "s3cret-value" });
197+ assert.deepEqual(build.build_env, { PUBLIC_URL: "https://example.com" });
198+ assert.match(build.token, /^[0-9a-f]{64}$/);
199+ assert.deepEqual(rows("SELECT kind, status, run_id, job_id, created_by, trusted FROM deployments"), [
200+ { kind: "production", status: "queued", run_id: "run_1", job_id: "job_1", created_by: "ada", trusted: 1 },
201+ ]);
202+ // The job runs it: no sandbox, and no compute reserved for it.
203+ assert.ok(!w.state.calls.includes("RUNNER start_deploy"));
204+ assert.ok(!w.state.calls.some((call) => call === "BILLING reserve" || call === "BILLING entitlements"));
205+ // The commit's status says it is building.
206+ assert.ok(w.state.calls.includes("WORK set_commit_status"));
207+});
208+
209+test("the job reports with the build's token, and its seconds are not charged again", async () => {
210+ const { value: build } = await open();
211+ assert.equal((await post(`/jobs/${build.deploy_id}/started`, { token: build.token })).ok, true);
212+ const finished = await post(`/jobs/${build.deploy_id}/finish`, {
213+ token: build.token,
214+ worker: { compatibilityDate: "2026-09-26", vars: {}, notFoundHandling: "single-page-application" },
215+ completionJwt: "done",
216+ buildSeconds: 42,
217+ detected: "static",
218+ });
219+ assert.equal(finished.ok, true, JSON.stringify(finished));
220+ assert.equal(rows("SELECT error FROM deployments")[0].error, null);
221+ assert.deepEqual(rows("SELECT status, build_seconds, detected FROM deployments"), [{ status: "ready", build_seconds: 42, detected: "static" }]);
222+ assert.deepEqual(rows("SELECT script, kind FROM apps"), [{ script: "web-acme", kind: "production" }]);
223+ assert.ok(!w.state.calls.includes("BILLING charge_feature"));
224+ assert.deepEqual(rows("SELECT build_seconds FROM meters WHERE build_seconds > 0"), []);
225+ // The token was for that build alone, until it finished.
226+ assert.equal((await post(`/jobs/${build.deploy_id}/started`, { token: build.token })).ok, false);
227+});
228+
229+test("another branch builds its preview; a pull request's run builds the pull request's", async () => {
230+ w.state.run = { ...w.state.run, ref: "refs/heads/feature/x" };
231+ const branch = (await open()).value;
232+ assert.deepEqual([branch.kind, branch.branch, branch.number], ["preview", "feature/x", null]);
233+ w.state.run = { ...w.state.run, event: "pull_request", ref: "refs/pull/9/merge", pull: 9 };
234+ const pull = (await open()).value;
235+ assert.deepEqual([pull.kind, pull.branch, pull.number, pull.trusted], ["preview", "feature", 9, true]);
236+ assert.deepEqual(w.state.resolved.map((r) => r.environment), ["preview", "preview"]);
237+});
238+
239+test("a pull request from a fork, or one that had to be approved, gets no build secrets", async () => {
240+ w.state.run = { ...w.state.run, event: "pull_request", ref: "refs/pull/9/merge", pull: 9 };
241+ w.state.pull = { ...w.state.pull, branch: null, fork: { namespace: "eve", name: "web" } };
242+ const fork = (await open()).value;
243+ assert.deepEqual([fork.branch, fork.trusted, fork.build_secrets], ["pr-9", false, {}]);
244+ w.state.pull = { ...w.state.pull, branch: "feature", fork: null };
245+ w.state.approval = { state: "approved" };
246+ const approved = (await open()).value;
247+ assert.deepEqual([approved.trusted, approved.build_secrets], [false, {}]);
248+ assert.deepEqual(w.state.resolved.map((r) => r.trusted), [false, false]);
249+});
250+
251+test("it is refused without a job's write token, after the job, without the plan, or with previews off", async () => {
252+ const refused = async (body: Record<string, unknown>) => (await open(body)).error?.code;
253+ assert.equal(await refused({ actor: jobActor(["deployments:read"]) }), "forbidden");
254+ assert.equal(await refused({ actor: { ...jobActor(), token: { token_id: "tok_person", scopes: null } } }), "forbidden");
255+ assert.equal(await refused({ repo: { namespace: "acme", name: "api" } }), "forbidden");
256+ assert.equal(await refused({ project: "docs" }), "not_found");
257+ w.state.jobStatus = "completed";
258+ assert.equal(await refused({}), "conflict");
259+ w.state.jobStatus = "in_progress";
260+ w.state.run = { ...w.state.run, ref: "refs/tags/v1.0.0" };
261+ assert.equal(await refused({}), "invalid");
262+ w.state.run = { ...w.state.run, ref: "refs/heads/main" };
263+ w.state.plan = false;
264+ const unpaid = await open();
265+ assert.deepEqual([unpaid.ok, unpaid.error.code], [false, "payment_required"]);
266+ assert.deepEqual(rows("SELECT status FROM deployments"), [{ status: "skipped" }]);
267+ w.state.plan = true;
268+ w.state.sqlite.prepare("UPDATE settings SET previews = 0").run();
269+ w.state.run = { ...w.state.run, ref: "refs/heads/feature" };
270+ assert.equal(await refused({}), "conflict");
271+ assert.ok(!w.state.calls.includes("RUNNER start_deploy"));
272+});
+94−0
1+/**
2+ * Who may open a build from a workflow, what a run builds, and whether it
3+ * is trusted. `@g1t/contracts` imports its modules without extensions, so
4+ * a resolve hook adds them (as in moves.flow.test.ts).
5+ */
6+
7+import assert from "node:assert/strict";
8+import { registerHooks } from "node:module";
9+import { test } from "node:test";
10+
11+import type { User } from "@g1t/contracts";
12+
13+registerHooks({
14+ resolve(specifier, context, next) {
15+ try {
16+ return next(specifier, context);
17+ } catch (error) {
18+ if (specifier.startsWith(".")) return next(`${specifier}.ts`, context);
19+ throw error;
20+ }
21+ },
22+});
23+const { commitOf, jobOf, projectNamed, runTrusted, targetOf } = await import("./workflow-builds.ts");
24+
25+const repo = { namespace: "acme", name: "web" };
26+const jobToken = (token: Partial<NonNullable<User["token"]>>): User =>
27+ ({
28+ id: "wsp_acme",
29+ username: "acme",
30+ kind: "workspace",
31+ verified: true,
32+ workspaces: [{ slug: "acme", role: "member" }],
33+ token: { token_id: "tok_1", repo: "acme/web", job: { run_id: "run_1", job_id: "job_1" }, scopes: ["deployments:write"], ...token },
34+ }) as User;
35+
36+test("only a workflow job's token for the repository, with deployments: write, opens a build", () => {
37+ assert.deepEqual(jobOf(jobToken({}), repo), { runId: "run_1", jobId: "job_1" });
38+ // write-all, and contents with deployments.
39+ assert.ok(!("error" in jobOf(jobToken({ scopes: ["contents:read", "deployments:write"] }), repo)));
40+ // A person's token, however wide, is not a job's.
41+ const person = { id: "usr_1", username: "ada", kind: "user", verified: true, token: { token_id: "tok_2", scopes: null } } as User;
42+ assert.match((jobOf(person, repo) as { error: string }).error, /g1t Actions job's token/);
43+ assert.match((jobOf(null, repo) as { error: string }).error, /g1t Actions job's token/);
44+ // Read only: what a pull request from outside gets.
45+ assert.match((jobOf(jobToken({ scopes: ["deployments:read", "contents:read"] }), repo) as { error: string }).error, /deployments: write/);
46+ // Another repository's job.
47+ assert.match((jobOf(jobToken({ repo: "acme/api" }), repo) as { error: string }).error, /acme\/api, not acme\/web/);
48+ // Case does not matter in names.
49+ assert.ok(!("error" in jobOf(jobToken({ repo: "Acme/Web" }), repo)));
50+});
51+
52+test("a run on the production branch builds production; another branch, its preview; a pull request, its own", () => {
53+ for (const event of ["push", "workflow_dispatch", "schedule"]) {
54+ assert.deepEqual(targetOf({ event, ref: "refs/heads/main", pull: null }, "main"), { kind: "production" });
55+ }
56+ assert.deepEqual(targetOf({ event: "push", ref: "refs/heads/feature/x", pull: null }, "main"), { kind: "branch", branch: "feature/x" });
57+ assert.deepEqual(targetOf({ event: "push", ref: "main", pull: null }, "main"), { kind: "production" });
58+ assert.deepEqual(targetOf({ event: "pull_request", ref: "refs/pull/9/merge", pull: 9 }, "main"), { kind: "pull", number: 9 });
59+ assert.deepEqual(targetOf({ event: "pull_request_target", ref: "refs/heads/main", pull: 9 }, "main"), { kind: "pull", number: 9 });
60+ // The production branch is the project's, whatever it is called.
61+ assert.deepEqual(targetOf({ event: "push", ref: "refs/heads/main", pull: null }, "trunk"), { kind: "branch", branch: "main" });
62+ // A tag deploys nothing.
63+ assert.match((targetOf({ event: "push", ref: "refs/tags/v1.0.0", pull: null }, "main") as { error: string }).error, /refs\/tags\/v1.0.0/);
64+});
65+
66+test("a run from a fork, from someone who cannot push, or one that needed approval gets no secrets", () => {
67+ assert.equal(runTrusted({ approval: null, pull: null, insider: true }), true);
68+ assert.equal(runTrusted({ approval: null, pull: { fork: null }, insider: true }), true);
69+ assert.equal(runTrusted({ approval: null, pull: { fork: { namespace: "eve", name: "web" } }, insider: true }), false);
70+ assert.equal(runTrusted({ approval: null, pull: { fork: null }, insider: false }), false);
71+ assert.equal(runTrusted({ approval: { state: "approved" }, pull: { fork: null }, insider: true }), false);
72+ assert.equal(runTrusted({ approval: { state: "required" }, pull: null, insider: true }), false);
73+});
74+
75+test("the commit is the run's unless one is given", () => {
76+ const run = { sha: "4b8d0f2a6c1e3579bd02468ace13579bdf02468a" };
77+ assert.equal(commitOf(undefined, run), run.sha);
78+ assert.equal(commitOf("", run), run.sha);
79+ assert.equal(commitOf(" ABCDEF1 ", run), "abcdef1");
80+ assert.ok(typeof commitOf("main", run) === "object");
81+ assert.ok(typeof commitOf(7, run) === "object");
82+});
83+
84+test("the project is the one named, or the only one with Deployments on", () => {
85+ const web = { slug: "web", on: true };
86+ const docs = { slug: "docs", on: false };
87+ const api = { slug: "api", on: true };
88+ const on = (p: { on: boolean }) => p.on;
89+ assert.equal(projectNamed([web, docs], on, null), web);
90+ assert.equal(projectNamed([web, docs], on, "Docs"), docs);
91+ assert.deepEqual(projectNamed([web, docs], on, "nope"), { error: "This repository has no project nope.", code: "not_found" });
92+ assert.deepEqual(projectNamed([docs], on, undefined), { error: "No project of this repository has Deployments on.", code: "conflict" });
93+ assert.deepEqual(projectNamed([web, api], on, ""), { error: "Name the project to build: web, api.", code: "invalid" });
94+});
+135−0
1+/**
2+ * Builds opened from a g1t Actions job (`start_from_workflow`): who may
3+ * open one, what the run builds, and whether it is trusted with the
4+ * project's build secrets. Pure, so it is tested apart from the service.
5+ *
6+ * The job builds in its own checkout and pays for its own time; the
7+ * service only opens the build (its row, its token, the commit's status
8+ * and what the project's settings give it) and puts what the job sends.
9+ */
10+
11+import { tokenAllows, type RepoPath, type User } from "@g1t/contracts";
12+
13+/** The run a job token was made for, as far as these rules read it. */
14+export type RunFacts = {
15+ event: string;
16+ ref: string;
17+ sha: string;
18+ pull: number | null;
19+ status: string;
20+};
21+
22+/** What a run deploys: production, a branch's preview, or a pull request's (its branch looked up). */
23+export type RunTarget =
24+ | { kind: "production" }
25+ | { kind: "branch"; branch: string }
26+ | { kind: "pull"; number: number }
27+ | { error: string };
28+
29+/**
30+ * The run and job a caller's token was made for, if it is a workflow job's
31+ * token for `repo` that may write deployments; otherwise why not.
32+ */
33+export function jobOf(actor: User | null | undefined, repo: RepoPath): { runId: string; jobId: string } | { error: string } {
34+ const token = actor?.token;
35+ if (!token?.job?.run_id || !token.job.job_id) {
36+ return { error: "Only a g1t Actions job's token (G1T_TOKEN) can open a g1t.page build." };
37+ }
38+ const own = `${repo.namespace}/${repo.name}`.toLowerCase();
39+ if (!token.repo || token.repo.toLowerCase() !== own) {
40+ return { error: `This job's token is for ${token.repo ?? "another repository"}, not ${repo.namespace}/${repo.name}.` };
41+ }
42+ if (!tokenAllows(token.scopes, "deployments:write")) {
43+ return { error: "The job's token needs `deployments: write` in the workflow's or job's `permissions:`." };
44+ }
45+ return { runId: token.job.run_id, jobId: token.job.job_id };
46+}
47+
48+/**
49+ * What a run builds. A run for a pull request builds that pull request's
50+ * preview; a run on the project's production branch (a push, a dispatch
51+ * or a schedule there) builds production; a run on any other branch
52+ * builds that branch's preview. A tag deploys nothing: give the branch.
53+ */
54+export function targetOf(run: Pick<RunFacts, "event" | "ref" | "pull">, productionBranch: string): RunTarget {
55+ if (run.pull != null && run.event.startsWith("pull_request")) return { kind: "pull", number: run.pull };
56+ const ref = run.ref.trim();
57+ const branch = ref.startsWith("refs/heads/") ? ref.slice("refs/heads/".length) : ref.startsWith("refs/") ? null : ref;
58+ if (!branch) {
59+ return { error: `This run is for ${ref || "no ref"}: g1t.page deploys branches and pull requests. Run the workflow on a branch.` };
60+ }
61+ return branch === productionBranch ? { kind: "production" } : { kind: "branch", branch };
62+}
63+
64+/**
65+ * Whether a run's build gets the project's build secrets: never for a
66+ * pull request from a fork, one whose author could not push here, or a
67+ * run that had to be approved to start. A run on a branch of the
68+ * repository is trusted: only people who can push make one.
69+ */
70+export function runTrusted(input: {
71+ approval: { state: string } | null | undefined;
72+ pull: { fork?: RepoPath | null } | null;
73+ /** Whether whoever the pull request is for can push to the repository. */
74+ insider: boolean;
75+}): boolean {
76+ if (input.approval) return false;
77+ if (input.pull) return !input.pull.fork && input.insider;
78+ return true;
79+}
80+
81+/** The commit to build: the one given (a whole or abbreviated hex id), else the run's. */
82+export function commitOf(given: unknown, run: Pick<RunFacts, "sha">): string | { error: string } {
83+ if (given == null || given === "") return run.sha;
84+ if (typeof given !== "string" || !/^[0-9a-f]{7,64}$/i.test(given.trim())) {
85+ return { error: "`sha` is a commit's id, in hex." };
86+ }
87+ return given.trim().toLowerCase();
88+}
89+
90+/**
91+ * The project to build: the one named, or the repository's one project
92+ * with Deployments on when none is named.
93+ */
94+export function projectNamed<P extends { slug: string }>(
95+ projects: P[],
96+ enabled: (project: P) => boolean,
97+ slug: string | null | undefined,
98+): P | { error: string; code: "not_found" | "invalid" | "conflict" } {
99+ const wanted = typeof slug === "string" ? slug.trim().toLowerCase() : "";
100+ if (wanted) {
101+ return projects.find((p) => p.slug.toLowerCase() === wanted) ?? { error: `This repository has no project ${wanted}.`, code: "not_found" };
102+ }
103+ const on = projects.filter(enabled);
104+ if (on.length === 1) return on[0];
105+ if (on.length === 0) return { error: "No project of this repository has Deployments on.", code: "conflict" };
106+ return { error: `Name the project to build: ${on.map((p) => p.slug).join(", ")}.`, code: "invalid" };
107+}
108+
109+/**
110+ * What `start_from_workflow` answers, in `snake_case` as the API sends it:
111+ * the build's id and token for `g1t-runner deploy`, what it is, where it
112+ * will be served, and what the project gives its build.
113+ */
114+export type WorkflowBuild = {
115+ deploy_id: string;
116+ /** For this build alone, until it finishes: `DEPLOY_TOKEN`. */
117+ token: string;
118+ kind: "production" | "preview";
119+ project: string;
120+ branch: string | null;
121+ number: number | null;
122+ commit: string;
123+ /** The app's address on g1t.page. */
124+ url: string;
125+ /** Whether the build got the project's build secrets. */
126+ trusted: boolean;
127+ /** The project's settings, for a build that names none of its own. */
128+ root_dir: string;
129+ build_command: string | null;
130+ output_dir: string | null;
131+ /** The variables builds of this kind get. */
132+ build_env: Record<string, string>;
133+ /** The secrets builds of this kind get; empty for an untrusted run. */
134+ build_secrets: Record<string, string>;
135+};

Comments

Sign in to comment.