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
222 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
112 container sends is {money(offer.egressGbMicros, { precise: true })} a GB.
113 {!offer.available && " Docker deployments are not switched on for g1t.page yet: until they are, a container build fails, or builds the site the project is without its Dockerfile."}
114 </p>
115 </div>
116 );
117}
118
119export default function DeploymentSettings({ loaderData, actionData, params }: Route.ComponentProps) {
120 const { settings, notDeploying } = loaderData;
121 const base = `/${params.owner}/${params.repo}`;
122 return (
123 <div className="max-w-4xl">
124 <RepoSettingsHeading base={base} />
125 <div className="min-h-6">
126 {actionData && "notice" in actionData && <p className="text-sm text-success">{actionData.notice}</p>}
127 <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
128 </div>
129
130 <Detected kind={settings.detected ?? null} framework={settings.framework ?? null} />
131
132 {!settings.enabled ? (
133 <Card asChild className="p-5">
134 <section>
135 <h2 className="font-medium">Deployments are off</h2>
136 <p className="mt-1 text-sm text-muted">
137 Deployments are opt-in for each project, and nothing builds or runs until you turn them on. Then g1t builds
138 production at{" "}
139 <span className="font-mono text-fg">{settings.productionUrl.replace("https://", "")}</span> and a preview for
140 every pull request. The workspace 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
145 App or site, deployed on g1t, 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, and a workflow named g1t in .g1t/workflows takes its place.
173 </Check>
174 <div className="grid gap-4 md:grid-cols-2">
175 <Field label="Build as" hint="Auto finds it from the project's files: a Wrangler config is Workers, a framework that builds a static site stays one, and a Dockerfile or a server makes it a container.">
176 <SelectField name="buildAs" defaultValue={settings.buildAs ?? "auto"} aria-label="Build as" options={BUILD_AS_OPTIONS} />
177 </Field>
178 <Field label="Dockerfile" hint="For a container; Dockerfile or Containerfile is found by itself when empty.">
179 <Input name="dockerfile" defaultValue={settings.dockerfile ?? ""} placeholder="Dockerfile" />
180 </Field>
181 </div>
182 {settings.containers && <ContainerSizes offer={settings.containers} chosen={settings.instanceType ?? DEFAULT_APP_INSTANCE} />}
183 <div className="grid gap-4 md:grid-cols-3">
184 <Field label="Build command" hint="Instead of the project's own build script.">
185 <Input name="buildCommand" defaultValue={settings.buildCommand ?? ""} placeholder="npm run build" />
186 </Field>
187 <Field label="Output directory" hint="For a static site; found by itself when empty.">
188 <Input name="outputDir" defaultValue={settings.outputDir ?? ""} placeholder="dist" />
189 </Field>
190 <Field label="Idle days" hint="A preview no one visits for this long comes down.">
191 <Input name="idleDays" type="number" min={1} max={90} defaultValue={settings.idleDays} />
192 </Field>
193 </div>
194 <p className="text-sm text-muted">
195 Builds and running apps read the{" "}
196 <Link to={`${base}/settings/secrets`} className="text-fg hover:underline">
197 secrets and variables
198 </Link>{" "}
199 available to Deployments: each row for Production or Preview, or for all environments.
200 </p>
201 <SubmitButton name="intent" value="save" pending="Saving…">
202 Save
203 </SubmitButton>
204 </Form>
205
206 <section className="mt-10 rounded-xl border border-danger/30 p-5">
207 <h2 className="text-sm font-medium">Turn off deployments</h2>
208 <p className="mt-1 text-sm text-muted">
209 Takes production and every preview down now, and stops building. Nothing of this project's keeps
210 running or costing anything. The workspace's plan stays on; turn it off under Billing.
211 </p>
212 <Form method="post" className="mt-3">
213 <SubmitButton variant="outline" name="intent" value="disable" pending="Turning off…">
214 Turn off deployments
215 </SubmitButton>
216 </Form>
217 </section>
218 </>
219 )}
220 </div>
221 );
222}