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.

226 linesCodeBlameRaw
1import { Form, Link } from "react-router";
2
3import { DEFAULT_APP_INSTANCE, FRAMEWORK_NAMES, type ContainerOffer, type DetectedKind } from "@g1t/contracts";
4
5import type { Route } from "./+types/settings-deployments";
6import { page } from "../../lib/meta";
7import { RepoSettingsHeading } from "../../components/repo-settings-heading";
8import { ErrorText, Field, Input, SubmitButton } from "../../components/ui";
9import { Card } from "../../components/ui/card";
10import { SelectField } from "../../components/ui/select";
11import { SwitchCard } from "../../components/ui/switch";
12import { BUILD_AS_OPTIONS, KIND_COSTS, detectedLabel, sizeLabel } from "../../lib/deploy-kind";
13import { money } from "../../lib/money";
14import { neverDeploys } from "../../lib/project-kind";
15import { deployments, projects } from "../../lib/services.server";
16import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
17import { requireCapability, requireInsider } from "../../lib/access.server";
18
19export function meta({ params, ...args }: Route.MetaArgs) {
20 return page(args, { title: `Deployment settings · ${params.owner}/${params.repo} · g1t` });
21}
22
23export async function loader({ params, context }: Route.LoaderArgs) {
24 const viewer = getViewer(context);
25 // Admins; to anyone without a role here the page does not exist.
26 await requireInsider(context, params, "manage_integrations");
27 const [settings, project] = await Promise.all([
28 deployments.settings({ workspace: params.owner, slug: params.repo }, viewer),
29 projects.get(params.owner, params.repo, viewer).catch(() => null),
30 ]);
31 // Set not to deploy in General settings: turning them on is refused until that changes.
32 const notDeploying = project?.ok ? neverDeploys(project.value) : false;
33 return { settings: unwrap(settings), notDeploying };
34}
35
36export async function action({ request, params, context }: Route.ActionArgs) {
37 assertSameOrigin(request);
38 const user = requireUser(context, request);
39 await requireCapability(context, params, "manage_integrations");
40 const form = await request.formData();
41 const ref = { workspace: params.owner, slug: params.repo };
42 const intent = form.get("intent");
43 if (intent === "enable" || intent === "disable") {
44 const saved = await deployments.updateSettings(user, ref, { enabled: intent === "enable" });
45 return saved.ok
46 ? { notice: intent === "enable" ? "Deployments are on. Production is building." : "Deployments are off, and every app is down." }
47 : { error: saved.error.message };
48 }
49 const on = (name: string) => form.get(name) === "on";
50 const saved = await deployments.updateSettings(user, ref, {
51 previews: on("previews"),
52 production: on("production"),
53 buildCommand: String(form.get("buildCommand") ?? ""),
54 outputDir: String(form.get("outputDir") ?? ""),
55 idleDays: Number(form.get("idleDays")),
56 viaWorkflow: on("viaWorkflow"),
57 buildAs: String(form.get("buildAs") ?? "auto") as "auto",
58 dockerfile: String(form.get("dockerfile") ?? ""),
59 instanceType: String(form.get("instanceType") ?? DEFAULT_APP_INSTANCE),
60 });
61 return saved.ok ? { notice: "Saved." } : { error: saved.error.message };
62}
63
64function Check({ name, on, title, children }: { name: string; on: boolean; title: string; children: string }) {
65 return (
66 <SwitchCard name={name} defaultChecked={on} title={title}>
67 {children}
68 </SwitchCard>
69 );
70}
71
72/** What g1t found the project to be at its last build, with what it was built with, and what that costs. */
73function Detected({ kind, framework }: { kind: DetectedKind | null; framework: string | null }) {
74 return (
75 <Card asChild className="mb-6 p-5">
76 <section>
77 <h2 className="text-sm font-medium">What g1t detected</h2>
78 <p className="mt-1 text-sm">
79 {kind ? detectedLabel(kind, framework, FRAMEWORK_NAMES) : <span className="text-muted">Detected at the first build</span>}
80 </p>
81 <p className="mt-1 text-xs text-muted">
82 {kind
83 ? KIND_COSTS[kind]
84 : "A static site's files cost nothing to serve; a Workers project's requests and CPU time are metered; a container is metered while it is awake. Builds are metered either way."}
85 </p>
86 </section>
87 </Card>
88 );
89}
90
91/** Each container size with what an hour awake costs, for choosing one. */
92function ContainerSizes({ offer, chosen }: { offer: ContainerOffer; chosen: string }) {
93 return (
94 <div className="space-y-3">
95 <Field
96 label="Container size"
97 hint={`For a container: what it runs at, and how large its image can be. It sleeps after ${offer.sleepMinutes} minutes without a request and costs nothing asleep.`}
98 >
99 <SelectField
100 name="instanceType"
101 defaultValue={chosen}
102 aria-label="Container size"
103 options={offer.prices.map((size) => ({
104 value: size.type,
105 label: `${size.type}: ${sizeLabel(size)}`,
106 description: `${money(size.hourlyMicros, { precise: true })} an hour awake, at most`,
107 }))}
108 />
109 </Field>
110 <p className="text-xs text-muted">
111 Charged by the minute while awake, at what Cloudflare charges g1t plus 20%, with every vCPU counted as busy; data a container sends is{" "}
112 {money(offer.egressGbMicros, { precise: true })} a GB.
113 {!offer.available &&
114 " Docker deployments are switched off on this g1t: a container build fails, or builds the site the project is without its Dockerfile."}
115 </p>
116 </div>
117 );
118}
119
120export default function DeploymentSettings({ loaderData, actionData, params }: Route.ComponentProps) {
121 const { settings, notDeploying } = loaderData;
122 const base = `/${params.owner}/${params.repo}`;
123 return (
124 <div className="max-w-4xl">
125 <RepoSettingsHeading base={base} />
126 <div className="min-h-6">
127 {actionData && "notice" in actionData && <p className="text-sm text-success">{actionData.notice}</p>}
128 <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
129 </div>
130
131 <Detected kind={settings.detected ?? null} framework={settings.framework ?? null} />
132
133 {!settings.enabled ? (
134 <Card asChild className="p-5">
135 <section>
136 <h2 className="font-medium">Deployments are off</h2>
137 <p className="mt-1 text-sm text-muted">
138 Deployments are opt-in for each project, and nothing builds or runs until you turn them on. Then g1t builds production at{" "}
139 <span className="font-mono text-fg">{settings.productionUrl.replace("https://", "")}</span> and a preview for every pull request. The workspace
140 needs the g1t plan, under Billing.
141 </p>
142 {notDeploying ? (
143 <p className="mt-4 text-sm text-muted">
144 This project is set to be something that doesn't deploy, such as a library or a tool. To deploy it, choose App or site, hosted on g1t.page,
145 under{" "}
146 <Link to={`${base}/settings#kind`} className="text-fg underline underline-offset-4">
147 General
148 </Link>{" "}
149 first.
150 </p>
151 ) : (
152 <Form method="post" className="mt-4">
153 <SubmitButton variant="accent" name="intent" value="enable" pending="Turning on…">
154 Turn on deployments
155 </SubmitButton>
156 </Form>
157 )}
158 </section>
159 </Card>
160 ) : (
161 <>
162 <Form method="post" className="space-y-5">
163 <div className="grid gap-3 md:grid-cols-2">
164 <Check name="production" on={settings.production} title="Production">
165 Deploy the default branch on every push.
166 </Check>
167 <Check name="previews" on={settings.previews} title="Previews">
168 A preview for every branch with an open pull request, linked on it.
169 </Check>
170 </div>
171 <Check name="viaWorkflow" on={settings.viaWorkflow ?? false} title="Build with a workflow">
172 Build through a workflow g1t manages for this project, listed under Actions with its runs and logs. It runs on g1t's machines like any workflow,
173 and a workflow named g1t in .g1t/workflows takes its place.
174 </Check>
175 <div className="grid gap-4 md:grid-cols-2">
176 <Field
177 label="Build as"
178 hint="Auto finds it from the project's files, preferring a site or a Worker to a container: a Wrangler config is Workers; a framework, a build script or an index.html is a site, even beside a Dockerfile; a Node.js server runs as a Worker; other servers, and Node.js servers that cannot run as a Worker, are containers. Choose Container to use the Dockerfile. The build's log says which it chose and why."
179 >
180 <SelectField name="buildAs" defaultValue={settings.buildAs ?? "auto"} aria-label="Build as" options={BUILD_AS_OPTIONS} />
181 </Field>
182 <Field label="Dockerfile" hint="For a container; Dockerfile or Containerfile is found by itself when empty.">
183 <Input name="dockerfile" defaultValue={settings.dockerfile ?? ""} placeholder="Dockerfile" />
184 </Field>
185 </div>
186 {settings.containers && <ContainerSizes offer={settings.containers} chosen={settings.instanceType ?? DEFAULT_APP_INSTANCE} />}
187 <div className="grid gap-4 md:grid-cols-3">
188 <Field label="Build command" hint="Instead of the project's own build script.">
189 <Input name="buildCommand" defaultValue={settings.buildCommand ?? ""} placeholder="npm run build" />
190 </Field>
191 <Field label="Output directory" hint="For a static site; found by itself when empty.">
192 <Input name="outputDir" defaultValue={settings.outputDir ?? ""} placeholder="dist" />
193 </Field>
194 <Field label="Idle days" hint="A preview no one visits for this long comes down.">
195 <Input name="idleDays" type="number" min={1} max={90} defaultValue={settings.idleDays} />
196 </Field>
197 </div>
198 <p className="text-sm text-muted">
199 Builds and running apps read the{" "}
200 <Link to={`${base}/settings/secrets`} className="text-fg hover:underline">
201 secrets and variables
202 </Link>{" "}
203 available to Deployments: each row for Production or Preview, or for all environments.
204 </p>
205 <SubmitButton name="intent" value="save" pending="Saving…">
206 Save
207 </SubmitButton>
208 </Form>
209
210 <section className="mt-10 rounded-xl border border-danger/30 p-5">
211 <h2 className="text-sm font-medium">Turn off deployments</h2>
212 <p className="mt-1 text-sm text-muted">
213 Takes production and every preview down now, and stops building. Nothing of this project's keeps running or costing anything. The workspace's plan
214 stays on; turn it off under Billing.
215 </p>
216 <Form method="post" className="mt-3">
217 <SubmitButton variant="outline" name="intent" value="disable" pending="Turning off…">
218 Turn off deployments
219 </SubmitButton>
220 </Form>
221 </section>
222 </>
223 )}
224 </div>
225 );
226}