Skip to content

Commit

Merge branch 'worktree-agent-a5b0d9e2733e9e382'

syntaqxcommitted Parents5d6cbc4fd62863Browse files
30 files+1444−1550/30 viewed
+13−5
181181 seconds for the page to settle, then takes it as it is.
182182 - The screenshot is of the app's `g1t.page` address. A custom domain
183183 serves the same app, so it looks the same.
184−- Until it is ready, or if it could not be taken, the overview shows a
185− plain frame with the address. A failed screenshot is tried again when
186− the overview is next opened, at most every five minutes.
187−- Only people who can read the project's repository see it, like the
188− rest of its deployments.
184+- Until it is ready, or if it could not be taken, the overview shows the
185+ project's uploaded image, if it has one, or else a plain frame with the
186+ address. A failed screenshot is tried again when the overview is next
187+ opened, at most every five minutes.
188+- Only people with a role on the project's repository see it, like the
189+ rest of its deployments; anyone else who can see the project sees its
190+ uploaded image or the frame.
191+- With **Use the uploaded image instead of a screenshot** on, the
192+ uploaded image shows instead.
189193 - Screenshots are not charged.
190194
195+An app deployed elsewhere gets a screenshot of its production address
196+too, and any project can show an uploaded image instead. See
197+[production's picture](/guides/projects/#productions-picture).
198+
191199 The [checklist on the overview](/guides/projects/#get-to-production) also
192200 tracks the first production deploy, a custom domain and a first preview.
193201
+61−2
5454 | Part | What it shows |
5555 | --- | --- |
5656 | **What it is** | A badge, such as **App, deployed elsewhere** or **Library**, beside its homepage and docs. People who can change its settings open the badge to change [what it is](#what-a-project-is) in one click. |
57−| **Production** | For an app [deployed on g1t](/guides/deployments/): a screenshot of the live site, which opens it; its address, the commit it runs and when it went up; **Visit** and **Redeploy**. For an app deployed elsewhere: production's address and **Visit**, with the default branch's latest commit. For an app nobody has placed yet: one question, where it runs. |
57+| **Production** | [Production's picture](#productions-picture), which opens the live site, beside the rest. For an app [deployed on g1t](/guides/deployments/): its address, the commit it runs and when it went up; **Visit** and **Redeploy**. For an app deployed elsewhere: production's address and **Visit**, with its latest reported production deployment or the default branch's latest commit. For an app nobody has placed yet: one question, where it runs. |
5858 | **Packages** | For a library or a tool, in place of Production: the packages its repository publishes, each with its latest version and how to install it, or how to publish a first one. |
5959 | **Documentation**, **Homepage** | For docs, where they are read; for anything else, its homepage. |
6060 | **Get to production**, **Ship a release** or **Get started** | The [checklist](#the-checklist) for what the project is, until every step is done or you dismiss it. |
6565 | **About** | Its description, its [links](#links), what it is, and its latest release (its newest tag). People who can change its settings edit the description and links from here. |
6666 | **Health**, **Clone** | How often checks pass (on pull requests, or the project's workflow runs when it ships without them), recent builds and open issues by age; the clone address. |
6767
68+### Production's picture
69+
70+The production card always has a picture of the app, wherever it runs.
71+It is the first of these there is:
72+
73+1. **A screenshot of production.** For an app deployed on g1t, g1t takes
74+ one each time production goes live (see
75+ [the production screenshot](/guides/deployments/#the-production-screenshot));
76+ people with a role on the repository see it, like the rest of its
77+ deployments. For an app deployed elsewhere, g1t takes one of
78+ production's address: the address its latest production deployment
79+ reported to g1t gives, or else the one set for it under **What it is**.
80+ Anyone who can see the project sees it.
81+2. **An image someone uploaded**, under **Settings** → **General** →
82+ **Production image**.
83+3. **A plain frame** with production's address. People who can change
84+ the project's settings see **Add an image of your app** on it, which
85+ opens **Production image**.
86+
87+A screenshot of production deployed elsewhere is taken:
88+
89+- when its address is set or changed;
90+- when a production deployment of it is reported to g1t and succeeds,
91+ through the [API](/guides/deployments/) or by a workflow job with an
92+ `environment:`;
93+- otherwise about once a day, when the one kept is a day old. Opening the
94+ overview after that also takes a new one, in the background, while the
95+ old one shows.
96+
97+g1t waits up to 12 seconds for the page to settle, as a 1280 by 800
98+browser window sees it, then takes it as it is. Only public `http` and
99+`https` addresses on their usual ports are visited: an address on a
100+private network, a loopback or link-local address, or one of g1t's own
101+service addresses (other than `g1t.sh` itself) gets the uploaded image
102+or the frame instead. Screenshots are not charged.
103+
104+#### Uploading an image
105+
106+**Production image** takes a PNG, JPEG or WebP of at most 5 MB; wide
107+images fit best, such as 1280 by 800. It is checked by its contents, not
108+its name, and served from g1t's user-content address
109+(`g1tusercontent.com` on g1t.sh), never from the site itself. Its address
110+is made from its contents, so the card shows a new image as soon as it
111+is uploaded. Anyone with an image's address can open it, even for a
112+private project, so upload only what you would show.
113+
114+- The uploaded image shows whenever there is no screenshot: before the
115+ first one, when one cannot be taken, or for an app with no production
116+ address.
117+- **Use the uploaded image instead of a screenshot** shows it in place of
118+ any screenshot. While it is on, g1t takes no screenshots of production
119+ deployed elsewhere.
120+- **Remove** takes it away; the card goes back to the screenshot or the
121+ frame.
122+
123+Anyone who can change the project's settings (the **Maintain** role on
124+its repository and up, as for production's address) can upload, replace
125+or remove it.
126+
68127 ### The checklist
69128
70129 People with a role on its repository see a card that counts what the
216275
217276 | Tab | What it holds |
218277 | --- | --- |
219−| **General** | The project's name and description, its source, its **root directory**, [what it is](#what-a-project-is), and its [links](#links). A project shows its repository's description, and follows it as it changes, until you give the project one of its own; **Use the repository's description** goes back. |
278+| **General** | The project's name and description, its source, its **root directory**, [what it is](#what-a-project-is), its [links](#links), and its [production image](#uploading-an-image). A project shows its repository's description, and follows it as it changes, until you give the project one of its own; **Use the repository's description** goes back. |
220279 | **Deployments** | Production, previews, build command, output directory and idle days. See [Deployments](/guides/deployments/#settings). |
221280 | **Domains** | Custom domains for production. See [custom domains](/guides/deployments/#custom-domains). |
222281 | **Agents** | How g1t's agents pick up work here, and what they read first. |
+103−0
1+import { ImageUp, LoaderCircle, Trash2 } from "lucide-react";
2+import { useRef } from "react";
3+import { useFetcher } from "react-router";
4+
5+import type { ProductionImage } from "@g1t/contracts";
6+
7+import { PRODUCTION_IMAGE_ACCEPT } from "../lib/production-image";
8+import { ErrorText, SubmitButton, usePending } from "./ui";
9+import { Button } from "./ui/button";
10+import { Card } from "./ui/card";
11+import { SwitchCard } from "./ui/switch";
12+
13+/**
14+ * A project's production image, in its settings: the one shown now, a
15+ * button to pick a new one (sent as soon as it is picked), a way to remove
16+ * it, and whether it is shown instead of a screenshot. Posts
17+ * `intent=production-image` with the file in `image`,
18+ * `intent=remove-production-image`, or `intent=prefer-production-image`
19+ * with `preferred`.
20+ */
21+export function ProductionImageField({
22+ image,
23+ src,
24+ screenshots,
25+}: {
26+ image: ProductionImage | null;
27+ /** Where the image is served, when there is one. */
28+ src: string | null;
29+ /** Whether production gets a screenshot by itself: on g1t.page, or at an address elsewhere. */
30+ screenshots: boolean;
31+}) {
32+ const upload = useFetcher<{ error?: string }>();
33+ const prefer = useFetcher<{ error?: string }>();
34+ const form = useRef<HTMLFormElement>(null);
35+ const uploading = usePending({ intent: "production-image" }, upload);
36+ const removing = usePending({ intent: "remove-production-image" }, upload);
37+ const busy = uploading || removing;
38+ // Shown as asked while it is being saved.
39+ const preferred = prefer.formData ? prefer.formData.get("preferred") === "on" : (image?.preferred ?? false);
40+ return (
41+ <Card className="p-4">
42+ <div className="flex flex-col gap-4 sm:flex-row sm:items-start">
43+ <div className="aspect-16/10 w-full shrink-0 overflow-hidden rounded-lg border border-line bg-bg sm:w-48">
44+ {src ? (
45+ <img src={src} alt="The uploaded image of production" width={1280} height={800} className="size-full object-cover object-top" />
46+ ) : (
47+ <div className="flex size-full items-center justify-center text-faint">
48+ <ImageUp size={20} aria-hidden="true" />
49+ </div>
50+ )}
51+ </div>
52+ <div className="min-w-0 grow space-y-2">
53+ <p className="text-sm text-muted">
54+ {screenshots
55+ ? "Shown on the overview while there is no screenshot of production, or instead of one."
56+ : "Shown on the overview's production card. Give production's address, and a screenshot of it is taken by itself."}
57+ </p>
58+ <div className="flex flex-wrap items-center gap-2">
59+ <upload.Form method="post" encType="multipart/form-data" ref={form}>
60+ <input type="hidden" name="intent" value="production-image" />
61+ <Button asChild variant="outline" className={busy ? "pointer-events-none opacity-50" : "cursor-pointer"}>
62+ <label>
63+ {uploading ? <LoaderCircle size={15} aria-hidden="true" className="animate-spin" /> : <ImageUp size={15} />}
64+ {uploading ? "Uploading…" : image ? "Upload a new image" : "Upload an image"}
65+ <input
66+ type="file"
67+ name="image"
68+ accept={PRODUCTION_IMAGE_ACCEPT}
69+ className="sr-only"
70+ onChange={(event) => {
71+ if (event.currentTarget.files?.length) form.current?.requestSubmit();
72+ }}
73+ />
74+ </label>
75+ </Button>
76+ </upload.Form>
77+ {image && (
78+ <upload.Form method="post">
79+ <input type="hidden" name="intent" value="remove-production-image" />
80+ <SubmitButton fetcher={upload} variant="outline" pending="Removing…" match={{ intent: "remove-production-image" }} disabled={uploading}>
81+ <Trash2 size={15} />
82+ Remove
83+ </SubmitButton>
84+ </upload.Form>
85+ )}
86+ </div>
87+ <p className="text-xs text-faint">PNG, JPEG or WebP, at most 5 MB. Wide images fit best, such as 1280 × 800.</p>
88+ <ErrorText>{upload.data?.error ?? prefer.data?.error}</ErrorText>
89+ </div>
90+ </div>
91+ {image && screenshots && (
92+ <SwitchCard
93+ className="mt-4"
94+ title="Use the uploaded image instead of a screenshot"
95+ checked={preferred}
96+ onCheckedChange={(on) => prefer.submit({ intent: "prefer-production-image", preferred: on ? "on" : "" }, { method: "post" })}
97+ >
98+ No screenshot of production is taken while it is on.
99+ </SwitchCard>
100+ )}
101+ </Card>
102+ );
103+}
+77−30
1−import { Globe } from "lucide-react";
1+import { Globe, ImagePlus } from "lucide-react";
22 import { useEffect, useRef, useState } from "react";
33
44 import { cn } from "../lib/cn";
5+import type { CardSource } from "../lib/production-image";
56 import { DeployLink } from "./deploy";
7+import { ButtonLink } from "./ui";
68
79 /**
8− * A thumbnail of production as it is now, linking to it. The screenshot is
9− * taken once per deploy; until it has loaded, or when there is none yet, a
10− * quiet frame with the address stands in.
10+ * A thumbnail of production, linking to it: a screenshot (taken once per
11+ * deploy on g1t.page, or of the address of production deployed
12+ * elsewhere), else an image someone uploaded (lib/production-image.ts
13+ * `cardSources`). Each source is tried in turn, the next when one does not
14+ * load. Until one has loaded, or when there is none, a quiet frame with
15+ * the address stands in; to someone who may change the project's
16+ * settings it offers to add an image.
1117 */
1218 export function ProductionShot({
13− src,
19+ sources,
1420 href,
1521 label,
22+ note,
23+ addImage,
1624 className,
1725 }: {
18− /** Null when nothing is deployed yet. */
19− src: string | null;
26+ /** The images to try, in order; empty for none. */
27+ sources: CardSource[];
2028 href: string | null;
2129 label: string;
30+ /** What the empty frame says, such as when a screenshot comes; none for a quiet frame. */
31+ note?: string | null;
32+ /** Where an image is added, for someone who may; null for anyone else. */
33+ addImage?: string | null;
2234 className?: string;
2335 }) {
2436 const image = useRef<HTMLImageElement>(null);
25− const [state, setState] = useState<"loading" | "loaded" | "failed">("loading");
37+ const [at, setAt] = useState(0);
38+ const [loaded, setLoaded] = useState(false);
39+ const key = sources.map((source) => source.src).join("\n");
40+ const source = sources[at] ?? null;
41+
42+ // Other sources (another deploy, a new upload): from the first again.
43+ useEffect(() => {
44+ setAt(0);
45+ setLoaded(false);
46+ }, [key]);
2647
2748 // An image that settled before hydration fired its events unheard.
2849 useEffect(() => {
2950 const img = image.current;
3051 if (!img?.complete) return;
31− setState(img.naturalWidth > 0 ? "loaded" : "failed");
32− }, [src]);
52+ if (img.naturalWidth > 0) setLoaded(true);
53+ else setAt((index) => index + 1);
54+ }, [source?.src]);
55+
56+ const empty = source == null;
57+ const waiting = !empty && !loaded ? (source.kind === "screenshot" ? "Taking a screenshot…" : "Loading the image…") : null;
3358
3459 const frame = (
3560 <span
3661 className={cn(
37− "relative block aspect-[16/10] w-full overflow-hidden rounded-lg border border-line bg-bg",
62+ "relative block aspect-16/10 w-full overflow-hidden rounded-lg border border-line bg-bg",
3863 href && "transition-colors group-hover:border-line-strong",
3964 )}
4065 >
41− {state !== "loaded" && <Placeholder label={label} waiting={src != null && state === "loading"} />}
42− {src && state !== "failed" && (
66+ {!loaded && <Placeholder label={label} waiting={waiting} note={empty && !addImage ? (note ?? null) : null} />}
67+ {source && (
4368 <img
69+ key={source.src}
4470 ref={image}
45− src={src}
46− alt={`Screenshot of ${label}`}
71+ src={source.src}
72+ alt={source.kind === "screenshot" ? `Screenshot of ${label}` : `Image of ${label}`}
4773 width={1280}
4874 height={800}
4975 decoding="async"
50− onLoad={() => setState("loaded")}
51− onError={() => setState("failed")}
76+ onLoad={() => setLoaded(true)}
77+ onError={() => {
78+ setLoaded(false);
79+ setAt((index) => index + 1);
80+ }}
5281 className={cn(
5382 "absolute inset-0 size-full object-cover object-top transition-opacity duration-300",
54− state === "loaded" ? "opacity-100" : "opacity-0",
83+ loaded ? "opacity-100" : "opacity-0",
5584 )}
5685 />
5786 )}
5887 </span>
5988 );
6089
61− return href ? (
62− <DeployLink href={href} className={cn("group block", className)} aria-label={`Visit ${label}`}>
63− {frame}
64− </DeployLink>
65− ) : (
66− <div className={className}>{frame}</div>
90+ return (
91+ <div className={cn("relative", className)}>
92+ {href ? (
93+ <DeployLink href={href} className="group block" aria-label={`Visit ${label}`}>
94+ {frame}
95+ </DeployLink>
96+ ) : (
97+ frame
98+ )}
99+ {empty && addImage && (
100+ // Beside the link, not in it: a link holds no other link.
101+ <span className="pointer-events-none absolute inset-x-0 top-7 bottom-0 flex flex-col items-center justify-center gap-2 px-3 text-center">
102+ <ButtonLink to={addImage} variant="outline" size="xs" className="pointer-events-auto bg-surface">
103+ <ImagePlus size={13} />
104+ Add an image of your app
105+ </ButtonLink>
106+ {note && <span className="text-[0.6875rem] text-faint">{note}</span>}
107+ </span>
108+ )}
109+ </div>
67110 );
68111 }
69112
70−function Placeholder({ label, waiting }: { label: string; waiting: boolean }) {
113+/** The frame without a picture: a browser's bar with the address, and faint lines of a page. */
114+function Placeholder({ label, waiting, note }: { label: string; waiting: string | null; note: string | null }) {
115+ const line = waiting ?? note;
71116 return (
72117 <span className="absolute inset-0 flex flex-col">
73118 <span className="flex items-center gap-1.5 border-b border-line bg-surface px-2.5 py-1.5">
74119 <span className="size-1.5 rounded-full bg-line-strong" />
75120 <span className="size-1.5 rounded-full bg-line-strong" />
76121 <span className="size-1.5 rounded-full bg-line-strong" />
77− <span className="ml-1.5 truncate rounded bg-bg px-2 py-px font-mono text-[0.625rem] text-faint">{label}</span>
122+ {label && <span className="ml-1.5 truncate rounded bg-bg px-2 py-px font-mono text-[0.625rem] text-faint">{label}</span>}
78123 </span>
79124 <span className="relative flex grow flex-col gap-2 p-3.5">
80125 <span className="h-2 w-2/5 rounded-full bg-line/80" />
81126 <span className="h-1.5 w-4/5 rounded-full bg-line/50" />
82127 <span className="h-1.5 w-3/5 rounded-full bg-line/50" />
83− <span className="mt-auto flex items-center gap-1.5 text-[0.6875rem] text-faint">
84− <Globe size={12} className={waiting ? "animate-pulse" : undefined} />
85− {waiting ? "Taking a screenshot…" : "A screenshot appears after the next deploy"}
86− </span>
128+ {line && (
129+ <span className="mt-auto flex items-center gap-1.5 text-[0.6875rem] text-faint">
130+ <Globe size={12} className={waiting ? "animate-pulse" : undefined} />
131+ {line}
132+ </span>
133+ )}
87134 </span>
88135 </span>
89136 );
+60−54
132132 deployment,
133133 commit,
134134 checks,
135+ shot,
135136 }: Head & {
136137 deployment?: ExternalDeployment | null;
137138 commit: { hash: string; authoredAt: string } | null;
138139 /** The commit's checks, as a badge; left out until there is one to show. */
139140 checks?: ReactNode;
141+ /** Production's picture, beside the rest, as an app on g1t has (components/production-shot.tsx). */
142+ shot?: ReactNode;
140143 }) {
141144 const url = deployment?.url ?? project.productionUrl;
142145 return (
143− <div className="flex flex-col gap-4 p-5 sm:flex-row sm:items-start sm:justify-between sm:p-6">
144− <div className="min-w-0">
145− <HeadLabel icon={<Rocket />}>Production</HeadLabel>
146− {url ? (
147− <>
148− <BigLink url={url} />
149− <p className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted">
150− {deployment ? (
151− <>
152− <StatusDot status={deploymentStatus(deployment.state)} />
153− <span className="inline-flex items-center gap-1 font-mono">
154− <GitCommitHorizontal size={13} className="text-faint" />
155− {deployment.sha.slice(0, 7)}
156− </span>
157− <span>
158− deployed <TimeAgo at={deployment.created_at} />
159− </span>
160− </>
161− ) : (
162− <>
163− <span>Deployed by its own pipeline</span>
164− {commit && (
165− <span className="inline-flex items-center gap-1.5">
166− <span className="text-faint">·</span>
167− <Link to={`${base}/commit/${commit.hash}`} className="inline-flex items-center gap-1 font-mono hover:text-fg">
168− <GitCommitHorizontal size={13} className="text-faint" />
169− {commit.hash.slice(0, 7)}
170− </Link>
171− {checks}
172− <TimeAgo at={commit.authoredAt} />
146+ <div className="flex flex-col gap-5 p-5 sm:flex-row sm:items-start sm:p-6">
147+ {shot}
148+ <div className="flex min-w-0 grow flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
149+ <div className="min-w-0">
150+ <HeadLabel icon={<Rocket />}>Production</HeadLabel>
151+ {url ? (
152+ <>
153+ <BigLink url={url} />
154+ <p className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted">
155+ {deployment ? (
156+ <>
157+ <StatusDot status={deploymentStatus(deployment.state)} />
158+ <span className="inline-flex items-center gap-1 font-mono">
159+ <GitCommitHorizontal size={13} className="text-faint" />
160+ {deployment.sha.slice(0, 7)}
161+ </span>
162+ <span>
163+ deployed <TimeAgo at={deployment.created_at} />
173164 </span>
174− )}
175− </>
176− )}
177− </p>
178− </>
179− ) : canChange ? (
180− <>
181− <p className="mt-2 text-lg font-medium">Where is production?</p>
182− <p className="mt-1 max-w-lg text-sm text-muted">
183− {project.name} is deployed by its own pipeline. Give production's address, and this card links to it.
184− </p>
185− <AddressForm field="productionUrl" placeholder="https://example.com" label="Production's address" extra={{ intent: "kind", choice: "elsewhere" }} />
186− </>
187− ) : (
188− <p className="mt-2 text-sm text-muted">Deployed by its own pipeline.</p>
165+ </>
166+ ) : (
167+ <>
168+ <span>Deployed by its own pipeline</span>
169+ {commit && (
170+ <span className="inline-flex items-center gap-1.5">
171+ <span className="text-faint">·</span>
172+ <Link to={`${base}/commit/${commit.hash}`} className="inline-flex items-center gap-1 font-mono hover:text-fg">
173+ <GitCommitHorizontal size={13} className="text-faint" />
174+ {commit.hash.slice(0, 7)}
175+ </Link>
176+ {checks}
177+ <TimeAgo at={commit.authoredAt} />
178+ </span>
179+ )}
180+ </>
181+ )}
182+ </p>
183+ </>
184+ ) : canChange ? (
185+ <>
186+ <p className="mt-2 text-lg font-medium">Where is production?</p>
187+ <p className="mt-1 max-w-lg text-sm text-muted">
188+ {project.name} is deployed by its own pipeline. Give production's address, and this card links to it.
189+ </p>
190+ <AddressForm field="productionUrl" placeholder="https://example.com" label="Production's address" extra={{ intent: "kind", choice: "elsewhere" }} />
191+ </>
192+ ) : (
193+ <p className="mt-2 text-sm text-muted">Deployed by its own pipeline.</p>
194+ )}
195+ <DeployQuietly base={base} canDeploy={canDeploy} what="it" />
196+ </div>
197+ {url && (
198+ <div className="flex shrink-0 items-center gap-2">
199+ <ButtonLink to={url} variant="accent" reloadDocument target="_blank" rel="noopener noreferrer">
200+ Visit
201+ <ArrowUpRight size={14} />
202+ </ButtonLink>
203+ </div>
189204 )}
190− <DeployQuietly base={base} canDeploy={canDeploy} what="it" />
191205 </div>
192− {url && (
193− <div className="flex shrink-0 items-center gap-2">
194− <ButtonLink to={url} variant="accent" reloadDocument target="_blank" rel="noopener noreferrer">
195− Visit
196− <ArrowUpRight size={14} />
197− </ButtonLink>
198− </div>
199− )}
200206 </div>
201207 );
202208 }
+24−0
140140 return orderEnvironments(environments).find((env) => env.production_environment && env.latest != null) ?? null;
141141 }
142142
143+/**
144+ * Production deployed somewhere other than g1t.page (by g1t Actions, or
145+ * reported through the API), as an app deployed elsewhere shows it: the
146+ * production environment's latest deployment, when it is not a g1t.page
147+ * build. `created_at` is its latest status.
148+ */
149+export function reportedProduction(environments: readonly DeploymentEnvironment[]): {
150+ environment: string;
151+ url: string | null;
152+ state: string;
153+ sha: string;
154+ created_at: string;
155+} | null {
156+ const reported = productionEnvironment(environments);
157+ if (!reported?.latest || reported.latest.source === "g1t_page") return null;
158+ return {
159+ environment: reported.name,
160+ url: environmentUrl(reported),
161+ state: reported.latest.state,
162+ sha: reported.latest.sha,
163+ created_at: reported.latest.updated_at,
164+ };
165+}
166+
143167 /** The filters the list's address can carry, besides the page. */
144168 export const FILTER_KEYS = ["environment", "state", "source", "creator", "ref"] as const;
145169 export type FilterKey = (typeof FILTER_KEYS)[number];
+2−0
200200 assert.equal(await usercontentLimited(env, request(raw), raw), null);
201201 assert.equal((await usercontentLimited(env, request(raw), raw))?.status, 429);
202202 assert.equal(await usercontentLimited(env, request("/avatars/" + "a".repeat(64)), "/avatars/" + "a".repeat(64)), null);
203+ const image = "/production-images/" + "b".repeat(64);
204+ assert.equal(await usercontentLimited(env, request(image), image), null);
203205 assert.deepEqual(env.WEB_ANONYMOUS_LIMIT.keys, ["ip:203.0.113.9", "ip:203.0.113.9"]);
204206 });
+4−3
120120 /**
121121 * The 429 for a repository file on the usercontent origin past its limit,
122122 * or null to go on. Nothing there is signed in (it never sees the session
123− * cookie), so a file counts as a signed-out page from its address. Avatars
124− * and custom emoji are answered from cache and are not limited.
123+ * cookie), so a file counts as a signed-out page from its address. Avatars,
124+ * custom emoji and production images are answered from cache and are not
125+ * limited.
125126 */
126127 export async function usercontentLimited(env: FrontDoorLimits, request: Request, path: string): Promise<Response | null> {
127− if (path.startsWith("/avatars/") || path.startsWith("/emoji/")) return null;
128+ if (path.startsWith("/avatars/") || path.startsWith("/emoji/") || path.startsWith("/production-images/")) return null;
128129 const verdict = await checkLimit(env.WEB_ANONYMOUS_LIMIT, `ip:${clientAddress(request)}`);
129130 return verdict === "limited" ? tooManyRequests(PAGE_MESSAGE) : null;
130131 }
+87−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import {
5+ MAX_PRODUCTION_IMAGE_BYTES,
6+ cardSources,
7+ externalTarget,
8+ externalVersion,
9+ productionImageUrl,
10+ readProductionImageUpload,
11+ utcDay,
12+} from "./production-image.ts";
13+
14+const PNG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0];
15+const GIF = [...new TextEncoder().encode("GIF89a"), 0, 0, 0, 0, 0, 0];
16+
17+function form(bytes: number[] | Uint8Array | null, type = "image/png"): FormData {
18+ const data = new FormData();
19+ if (bytes) data.set("image", new File([Uint8Array.from(bytes)], "shot.png", { type }));
20+ return data;
21+}
22+
23+test("an uploaded image is served from the usercontent origin by its hash", () => {
24+ assert.equal(productionImageUrl("ab12", "https://g1tusercontent.com"), "https://g1tusercontent.com/production-images/ab12");
25+ assert.equal(productionImageUrl("ab12"), "/production-images/ab12");
26+});
27+
28+test("an upload is a PNG, JPEG or WebP of at most 5 MB, read by its bytes", async () => {
29+ const ok = await readProductionImageUpload(form(PNG));
30+ assert.ok("image" in ok);
31+ assert.deepEqual(await readProductionImageUpload(form(null)), { error: "Choose an image to upload." });
32+ assert.deepEqual(await readProductionImageUpload(form(GIF, "image/gif")), { error: "Use a PNG, JPEG or WebP image." });
33+ // Named a PNG, but it is not one.
34+ assert.deepEqual(await readProductionImageUpload(form([1, 2, 3, 4])), { error: "Use a PNG, JPEG or WebP image." });
35+ const big = new Uint8Array(MAX_PRODUCTION_IMAGE_BYTES + 1);
36+ big.set(PNG);
37+ assert.deepEqual(await readProductionImageUpload(form(big)), { error: "Use an image of at most 5 MB." });
38+});
39+
40+const now = Date.parse("2026-10-11T12:00:00Z");
41+const elsewhere = { kind: "app", runs: "elsewhere", productionUrl: "https://g1t.sh", productionImage: null };
42+const reported = { url: "https://app.example.com", sha: "ABC1234DEF5678ABC1234DEF5678ABC1234DEF56", created_at: "2026-10-11T10:00:00Z" };
43+
44+test("production elsewhere is taken at its address, versioned by the day", () => {
45+ assert.deepEqual(externalTarget(elsewhere, null, now), { url: "https://g1t.sh", commit: "2026-10-11", since: null });
46+ assert.equal(utcDay(now), "2026-10-11");
47+});
48+
49+test("a reported production deployment gives the address and the commit", () => {
50+ assert.deepEqual(externalTarget(elsewhere, reported, now), {
51+ url: "https://app.example.com",
52+ commit: reported.sha.toLowerCase(),
53+ since: reported.created_at,
54+ });
55+ // No address of its own: the project's.
56+ assert.equal(externalTarget(elsewhere, { ...reported, url: null }, now)?.url, "https://g1t.sh");
57+ // An app nobody said runs where, reporting production, runs elsewhere.
58+ assert.equal(externalTarget({ ...elsewhere, runs: null, productionUrl: null }, reported, now)?.url, "https://app.example.com");
59+});
60+
61+test("no screenshot when it is not elsewhere, has no address, or shows an upload", () => {
62+ assert.equal(externalTarget({ ...elsewhere, runs: "g1t" }, null, now), null);
63+ assert.equal(externalTarget({ ...elsewhere, runs: null }, null, now), null);
64+ assert.equal(externalTarget({ ...elsewhere, runs: null, kind: "library" }, reported, now), null);
65+ assert.equal(externalTarget({ ...elsewhere, productionUrl: null }, null, now), null);
66+ assert.equal(externalTarget({ ...elsewhere, productionUrl: "ftp://g1t.sh" }, null, now), null);
67+ assert.equal(externalTarget({ ...elsewhere, productionImage: { preferred: true } }, reported, now), null);
68+});
69+
70+test("a version changes with the deploy or the day", () => {
71+ assert.equal(externalVersion({ url: "https://g1t.sh", commit: "2026-10-11", since: null }), "2026-10-11-0");
72+ assert.equal(
73+ externalVersion({ url: "https://g1t.sh", commit: "abc1234", since: "2026-10-11T10:00:00Z" }),
74+ `abc1234-${Date.parse("2026-10-11T10:00:00Z")}`,
75+ );
76+});
77+
78+test("the card tries the screenshot, then the upload; a preferred upload alone", () => {
79+ const upload = { url: "https://g1tusercontent.com/production-images/ab", preferred: false };
80+ const shot = { src: "/a/b/production.jpg", kind: "screenshot" };
81+ const uploaded = { src: upload.url, kind: "upload" };
82+ assert.deepEqual(cardSources("/a/b/production.jpg", upload), [shot, uploaded]);
83+ assert.deepEqual(cardSources("/a/b/production.jpg", { ...upload, preferred: true }), [uploaded]);
84+ assert.deepEqual(cardSources(null, upload), [uploaded]);
85+ assert.deepEqual(cardSources("/a/b/production.jpg", null), [shot]);
86+ assert.deepEqual(cardSources(null, null), []);
87+});
+100−0
1+/**
2+ * The image on a project's production card: a screenshot of production
3+ * (of its app on g1t.page, or of its address when it is deployed
4+ * elsewhere), else an image someone uploaded, else a placeholder. An
5+ * uploaded image is served from the usercontent origin at
6+ * `/production-images/<sha256>`; the projects service checks it again by
7+ * its bytes and is the one that decides. No Workers imports, so it is
8+ * tested under Node.
9+ */
10+
11+import { sniffImage, toBase64 } from "./avatar-upload.ts";
12+
13+/** As `MAX_PRODUCTION_IMAGE_BYTES` in the contracts: kept here so tests run without them. */
14+export const MAX_PRODUCTION_IMAGE_BYTES = 5 * 1024 * 1024;
15+
16+/** What the file picker offers: PNG, JPEG and WebP. */
17+export const PRODUCTION_IMAGE_ACCEPT = "image/png,image/jpeg,image/webp";
18+
19+const TYPES = new Set(PRODUCTION_IMAGE_ACCEPT.split(","));
20+
21+/** Where an uploaded production image is served. */
22+export function productionImageUrl(file: string, usercontent = ""): string {
23+ return `${usercontent}/production-images/${file}`;
24+}
25+
26+export type ProductionImageUpload = { image: string } | { error: string };
27+
28+/** Reads the image in `field` of a submitted form, as base64 for the projects service. */
29+export async function readProductionImageUpload(form: FormData, field = "image"): Promise<ProductionImageUpload> {
30+ const file = form.get(field);
31+ if (!file || typeof file === "string" || file.size === 0) return { error: "Choose an image to upload." };
32+ if (file.size > MAX_PRODUCTION_IMAGE_BYTES) return { error: "Use an image of at most 5 MB." };
33+ const bytes = new Uint8Array(await file.arrayBuffer());
34+ const type = sniffImage(bytes);
35+ if (!type || !TYPES.has(type)) return { error: "Use a PNG, JPEG or WebP image." };
36+ return { image: toBase64(bytes) };
37+}
38+
39+/** A production deployment reported from outside g1t.page, as the overview shows it. */
40+export type ReportedProduction = { url: string | null; sha: string; created_at: string };
41+
42+/** A production deployed elsewhere, to take a screenshot of: its address and what it shows. */
43+export type ExternalTarget = {
44+ url: string;
45+ /** The reported deploy's commit, or the UTC day when none is reported. */
46+ commit: string;
47+ /** When the reported deploy was made; a screenshot from before it is taken again. */
48+ since: string | null;
49+};
50+
51+/** The UTC day of `now`. */
52+export function utcDay(now: number): string {
53+ return new Date(now).toISOString().slice(0, 10);
54+}
55+
56+/**
57+ * The production deployed elsewhere whose screenshot the card shows: at
58+ * the address its reported production deployment gives, else the
59+ * project's own. Null when it is not deployed elsewhere, has no address,
60+ * or shows its uploaded image instead.
61+ */
62+export function externalTarget(
63+ project: {
64+ kind: string;
65+ runs: string | null;
66+ productionUrl: string | null;
67+ productionImage: { preferred: boolean } | null;
68+ },
69+ reported: ReportedProduction | null,
70+ now: number,
71+): ExternalTarget | null {
72+ if (project.productionImage?.preferred) return null;
73+ // An app that has not said where it runs, but reports production from elsewhere, runs elsewhere.
74+ const elsewhere = project.runs === "elsewhere" || (project.runs === null && project.kind === "app" && reported != null);
75+ if (!elsewhere) return null;
76+ const url = reported?.url ?? project.productionUrl;
77+ if (!url || !/^https?:\/\//i.test(url)) return null;
78+ const sha = reported?.sha?.toLowerCase() ?? "";
79+ if (reported && /^[0-9a-f]{7,64}$/.test(sha)) return { url, commit: sha, since: reported.created_at };
80+ return { url, commit: utcDay(now), since: null };
81+}
82+
83+/** What names one screenshot of an external production, for its address on the site: changes with a deploy or a day. */
84+export function externalVersion(target: ExternalTarget): string {
85+ return `${target.commit}-${(target.since && Date.parse(target.since)) || 0}`;
86+}
87+
88+/**
89+ * The images the card tries, in order, falling to the next when one does
90+ * not load: the uploaded image alone when it is preferred; else the
91+ * screenshot, then the uploaded image. Empty: the placeholder.
92+ */
93+export function cardSources(screenshot: string | null, upload: { url: string; preferred: boolean } | null): CardSource[] {
94+ const uploaded: CardSource[] = upload ? [{ src: upload.url, kind: "upload" }] : [];
95+ if (upload?.preferred) return uploaded;
96+ return [...(screenshot ? [{ src: screenshot, kind: "screenshot" as const }] : []), ...uploaded];
97+}
98+
99+/** One image the card may show, and what it is. */
100+export type CardSource = { src: string; kind: "screenshot" | "upload" };
+1−0
77 * <usercontent>/<owner>/<repo>/raw/<ref>/<path> a file at a branch, tag or commit
88 * <usercontent>/avatars/<sha256> an uploaded avatar
99 * <usercontent>/emoji/<sha256> a workspace's custom emoji
10+ * <usercontent>/production-images/<sha256> a project's uploaded production image
1011 *
1112 * A public repository's files are there for anyone. A private one's carry
1213 * `?token=`, a signature the site makes for someone who can read the
+2−0
283283 route(":owner/:repo/add-ci", "routes/repo/add-ci.ts"),
284284 // A screenshot of a project's production, for its overview.
285285 route(":owner/:repo/production.jpg", "routes/repo/production-screenshot.ts"),
286+ // The same, of production deployed elsewhere, at its address.
287+ route(":owner/:repo/production-elsewhere.jpg", "routes/repo/production-elsewhere.ts"),
286288 // A file as it is, sent on to the usercontent origin (lib/usercontent.ts).
287289 route(":owner/:repo/raw/:ref/*", "routes/repo/raw.ts"),
288290 // A project: its overview first, its repository's code under Code. The
+40−20
102102 import { accessTo, countsFor, projectFor, refusal, repoFor, requireWritableRepo } from "../../lib/access.server";
103103 import { shotVersion } from "./production-screenshot";
104104 import { DeploymentsPanel } from "../../components/deployments-panel";
105−import { environmentUrl, productionEnvironment } from "../../lib/deployments";
105+import { reportedProduction } from "../../lib/deployments";
106+import { cardSources, externalTarget, externalVersion, productionImageUrl } from "../../lib/production-image";
106107 import { cloneUrl, useAddresses } from "../../lib/addresses";
107108 import { readBranches } from "../../lib/branches.server";
108109 import { commitChecksFor } from "../../lib/commit-checks.server";
372373 // reported through the API): the slot an app deployed elsewhere shows.
373374 const environments = await environmentsP;
374375 const environmentList = environments?.ok ? environments.value : null;
375− const reported = productionEnvironment(environmentList?.environments ?? []);
376− const deployment: ExternalDeployment | null =
377− reported?.latest && reported.latest.source !== "g1t_page"
378− ? {
379− environment: reported.name,
380− url: environmentUrl(reported),
381− state: reported.latest.state,
382− sha: reported.latest.sha,
383− created_at: reported.latest.updated_at,
384− }
385− : null;
376+ const deployment: ExternalDeployment | null = reportedProduction(environmentList?.environments ?? []);
386377 // An app that has not said where it runs, but reports production from
387378 // elsewhere, runs elsewhere.
388379 const found = ok(project);
452443 checklistTitle: plan.title,
453444 // Production as reported from outside g1t, for an app deployed elsewhere.
454445 deployment,
446+ // Its screenshot's address on the site (production-elsewhere.ts), when it has one to show.
447+ elsewhereShot: elsewhereShot(projectValue, deployment, base),
455448 // Every environment's latest deployment, for the Deployments panel.
456449 environments: environmentList,
457450 // About's languages and contributors, filled in by the files' About work.
697690 );
698691 }
699692
693+/**
694+ * Where the overview asks for the screenshot of production deployed
695+ * elsewhere, with `?v=` naming the reported deploy or the day; null when
696+ * there is none to show (lib/production-image.ts `externalTarget`).
697+ */
698+function elsewhereShot(project: Project | null | undefined, deployment: ExternalDeployment | null, base: string): string | null {
699+ const target = project ? externalTarget(project, deployment, Date.now()) : null;
700+ return target ? `${base}/production-elsewhere.jpg?v=${externalVersion(target)}` : null;
701+}
702+
700703 function Stat({ label, value, to }: { label: string; value: ReactNode; to?: string }) {
701704 const body = (
702705 <>
814817 : null;
815818 const stripLinks = project ? linksToShow({ ...project.links, custom: [] }, [headUrl]) : [];
816819 const addresses = useAddresses();
820+ // The production card's picture: an uploaded image, served from the
821+ // usercontent origin, and where someone who may change it adds one.
822+ const upload = project?.productionImage
823+ ? { url: productionImageUrl(project.productionImage.file, addresses.usercontent), preferred: project.productionImage.preferred }
824+ : null;
825+ const addImage = canChange ? `${base}/settings#production-image` : null;
826+ const elsewhereUrl = loaderData.deployment?.url ?? project?.productionUrl ?? null;
817827 const source = project?.source.kind === "hosted" ? project.source : null;
818828 const moving = agentsLive.length > 0 || columns.working.length + columns.checking.length > 0;
819829 useLiveRefresh(moving);
876886 deployment={loaderData.deployment}
877887 commit={commit}
878888 checks={commit ? <CommitChecksBadge checks={checks} sha={commit.hash} className="size-5" /> : undefined}
889+ shot={
890+ <ProductionShot
891+ sources={cardSources(loaderData.elsewhereShot, upload)}
892+ href={elsewhereUrl}
893+ label={elsewhereUrl ? host(elsewhereUrl).replace(/\/$/, "") : ""}
894+ addImage={addImage}
895+ className="w-full shrink-0 sm:w-60 lg:w-72"
896+ />
897+ }
879898 />
880899 ) : plan === "unknown" && project ? (
881900 <WhereItRuns project={project} base={base} canChange={canChange} canDeploy={member && loaderData.can.manage_integrations} />
887906 <LibraryHead library={library} project={project} base={base} workspace={params.owner} />
888907 ) : (
889908 <div className="flex flex-col gap-5 p-5 sm:flex-row sm:items-start sm:p-6">
890− {member && (production || settings?.enabled) && (
891− <ProductionShot
892− src={production ? `${base}/production.jpg?v=${shotVersion(production)}` : null}
893− href={production ? (productionUrl ?? production.url) : null}
894− label={host(production ? (productionUrl ?? production.url) : (settings?.productionUrl ?? ""))}
895− className="w-full shrink-0 sm:w-60 lg:w-72"
896− />
897− )}
909+ {/* Always a picture: the screenshot (members, like the deployments), the uploaded image, or the frame. */}
910+ <ProductionShot
911+ sources={cardSources(member && production ? `${base}/production.jpg?v=${shotVersion(production)}` : null, upload)}
912+ href={production ? (productionUrl ?? production.url) : null}
913+ label={production ? host(productionUrl ?? production.url) : settings?.productionUrl ? host(settings.productionUrl) : ""}
914+ note={!production && settings?.enabled ? "A screenshot appears after the next deploy" : null}
915+ addImage={addImage}
916+ className="w-full shrink-0 sm:w-60 lg:w-72"
917+ />
898918 <div className="flex min-w-0 grow flex-wrap items-start justify-between gap-4">
899919 <div className="min-w-0">
900920 <p className="flex items-center gap-2 text-xs font-medium tracking-wide text-muted uppercase">
+47−0
1+/**
2+ * A screenshot of a project's production deployed elsewhere, for its
3+ * overview: of the address its reported production deployment gives, else
4+ * the one set for it (lib/production-image.ts `externalTarget`). The
5+ * address is always the project's own, never one the request names, and
6+ * the og service visits only public ones. For anyone who can see the
7+ * project: it shows a public address. Kept by the browser for an hour; the
8+ * overview's `?v=` changes with each reported deploy and each day.
9+ */
10+import { env } from "cloudflare:workers";
11+
12+import type { Route } from "./+types/production-elsewhere";
13+import { reportedProduction } from "../../lib/deployments";
14+import { externalTarget } from "../../lib/production-image";
15+import { deployments } from "../../lib/services.server";
16+import { getViewer } from "../../lib/session.server";
17+import { projectFor } from "../../lib/access.server";
18+
19+const HOUR = "private, max-age=3600";
20+const BRIEF = "private, max-age=60";
21+
22+export async function loader({ params, context }: Route.LoaderArgs) {
23+ const viewer = getViewer(context);
24+ const none = (status: number) => new Response(null, { status, headers: { "cache-control": BRIEF } });
25+ if (!env.SCREENSHOTS) return none(404);
26+ const project = await projectFor(context, params).catch(() => null);
27+ if (!project?.ok) return none(404);
28+ const repo = project.value.source.kind === "hosted" ? project.value.source.repo : { namespace: params.owner, name: params.repo };
29+ const environments = await deployments.environments(repo, viewer).catch(() => null);
30+ const reported = reportedProduction(environments?.ok ? environments.value.environments : []);
31+ const target = externalTarget(project.value, reported, Date.now());
32+ if (!target) return none(404);
33+ const shot = await env.SCREENSHOTS.externalImage({ url: target.url, commit: target.commit, since: target.since ?? undefined }).catch(
34+ (error: unknown) => (console.warn("screenshot:", error), null),
35+ );
36+ if (!shot) return none(404);
37+ return new Response(shot.body, {
38+ headers: {
39+ "content-type": shot.contentType,
40+ "cache-control": HOUR,
41+ "x-content-type-options": "nosniff",
42+ // A picture of someone's site: shown, never run.
43+ "content-security-policy": "default-src 'none'; sandbox",
44+ "last-modified": new Date(shot.capturedAt).toUTCString(),
45+ },
46+ });
47+}
+37−1
1−import { Box, Code2, GitBranch, Link2, Rocket } from "lucide-react";
1+import { Box, Code2, GitBranch, Image as ImageIcon, Link2, Rocket } from "lucide-react";
22 import { useState } from "react";
33 import { Form, Link } from "react-router";
44
55 import type { Route } from "./+types/settings";
66 import { page } from "../../lib/meta";
7+import { useAddresses } from "../../lib/addresses";
8+import { productionImageUrl, readProductionImageUpload } from "../../lib/production-image";
79 import { KindFields, LinkFields } from "../../components/project-about";
10+import { ProductionImageField } from "../../components/production-image-field";
811 import { RepoSettingsHeading } from "../../components/repo-settings-heading";
912 import { ErrorText, Field, Input, SubmitButton, TimeAgo } from "../../components/ui";
1013 import { Card } from "../../components/ui/card";
4245 const user = requireUser(context, request);
4346 await requireCapability(context, params, "manage_settings");
4447 const form = await request.formData();
48+ // Production's picture on the overview: whoever may change its address may change it.
49+ const intent = form.get("intent");
50+ if (intent === "production-image" || intent === "remove-production-image" || intent === "prefer-production-image") {
51+ let change: { image?: string | null; preferred?: boolean };
52+ if (intent === "production-image") {
53+ const upload = await readProductionImageUpload(form);
54+ if ("error" in upload) return { error: upload.error };
55+ change = { image: upload.image };
56+ } else {
57+ change = intent === "remove-production-image" ? { image: null } : { preferred: form.get("preferred") === "on" };
58+ }
59+ const saved = await projects.setProductionImage(user, params.owner, params.repo, change);
60+ return saved.ok ? { imageSaved: true as const } : { error: saved.error.message };
61+ }
4562 const choice = form.get("choice") as KindChoice | null;
4663 const set = choice && choice in KIND_CHOICE_SETS ? KIND_CHOICE_SETS[choice] : null;
4764 const saved = await projects.update(user, params.owner, params.repo, {
6784 const blocked = stops && !neverDeploys(project) && deploymentsOn === true;
6885 const base = `/${params.owner}/${params.repo}`;
6986 const source = project.source.kind === "hosted" ? project.source : null;
87+ const { usercontent } = useAddresses();
7088 return (
7189 <div className="max-w-4xl">
7290 <RepoSettingsHeading base={base} />
210228 </div>
211229 <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
212230 </Form>
231+
232+ {/* Its own forms, sent as soon as an image is picked: outside the settings form. */}
233+ <section className="mt-10 scroll-mt-20 space-y-4" id="production-image">
234+ <h2 className="flex items-center gap-2 text-sm font-medium">
235+ <ImageIcon size={15} className="text-accent" />
236+ Production image
237+ </h2>
238+ <p className="max-w-2xl text-xs text-muted">
239+ The picture on its overview's production card. A screenshot is taken by itself for an app deployed on g1t,
240+ and of production's address for one deployed elsewhere, about once a day and after each production deployment
241+ reported to g1t.
242+ </p>
243+ <ProductionImageField
244+ image={project.productionImage}
245+ src={project.productionImage ? productionImageUrl(project.productionImage.file, usercontent) : null}
246+ screenshots={project.runs === "g1t" || (project.runs === "elsewhere" && !!project.productionUrl)}
247+ />
248+ </section>
213249 </div>
214250 );
215251 }
+7−0
4545 commit: string;
4646 capturedAt: string;
4747 } | null>;
48+ /** A production deployed elsewhere, at its address; `commit` is a deploy's commit or a UTC day. */
49+ externalImage(input: { url: string; commit: string; since?: string }): Promise<{
50+ body: ArrayBuffer;
51+ contentType: string;
52+ commit: string;
53+ capturedAt: string;
54+ } | null>;
4855 };
4956 BLOBS: KVNamespace;
5057 /** Uploaded avatars by SHA-256, with `{ contentType }`; written by identity. */
+8−2
1212 export const AVATAR_PATH = /^\/avatars\/([0-9a-f]{64})$/;
1313 /** A workspace's custom emoji, by the SHA-256 of its bytes: kept by chat under `emoji/<hash>` in the same namespace. */
1414 export const EMOJI_PATH = /^\/emoji\/([0-9a-f]{64})$/;
15+/** A project's uploaded production image, by the SHA-256 of its bytes: kept by projects under `production/<hash>` in the same namespace. */
16+export const PRODUCTION_IMAGE_PATH = /^\/production-images\/([0-9a-f]{64})$/;
1517 /** A file put in a Docs page, by its random key: kept by the artifacts service (services/artifacts). */
1618 export const DOCS_FILE_PATH = /^\/docs-files\/([0-9a-f]{64})$/;
1719 /** The only types identity stores, having checked each image's bytes. */
4244 if (avatar) return serveAvatar(env, ctx, method, avatar[1]!, new URL(request.url).origin);
4345 const emoji = EMOJI_PATH.exec(path);
4446 if (emoji) return serveAvatar(env, ctx, method, `emoji/${emoji[1]!}`, new URL(request.url).origin);
47+ const production = PRODUCTION_IMAGE_PATH.exec(path);
48+ if (production) return serveAvatar(env, ctx, method, `production/${production[1]!}`, new URL(request.url).origin);
4549 const docsFile = DOCS_FILE_PATH.exec(path);
4650 if (docsFile) return serveDocsFile(env, method, docsFile[1]!);
4751 const file = parseRawPath(path);
8488 }
8589
8690 /**
87− * An uploaded avatar, or a custom emoji (`key` is then `emoji/<hash>`). Its
91+ * An uploaded avatar, a custom emoji (`key` is then `emoji/<hash>`) or a
92+ * production image (`production/<hash>`). Its
8893 * address is its hash, so it never changes and is
8994 * kept for good: each data centre keeps it in its cache after the first
9095 * view, and storage is read about once per place, not once per visitor.
9499 async function serveAvatar(env: Env, ctx: ExecutionContext, method: string, key: string, origin: string): Promise<Response> {
95100 // The Workers runtime's own cache, which the DOM types do not know.
96101 const cache = (caches as unknown as { default: Cache }).default;
97− const cacheKey = new Request(`${origin}/${key.startsWith("emoji/") ? key : `avatars/${key}`}`, { method: "GET" });
102+ const path = key.startsWith("emoji/") ? key : key.startsWith("production/") ? `production-images/${key.slice("production/".length)}` : `avatars/${key}`;
103+ const cacheKey = new Request(`${origin}/${path}`, { method: "GET" });
98104 const cached = await cache.match(cacheKey);
99105 if (cached) {
100106 return method === "HEAD" ? new Response(null, { headers: cached.headers }) : cached;
+5−1
107107 ],
108108 ),
109109 // Projects follow their repositories, and wake on activity
110− // (projects/src/index.ts, activity.ts).
110+ // (projects/src/index.ts, activity.ts). A production deployment
111+ // reported from elsewhere asks for its screenshot.
111112 (
112113 "SUBSCRIBER_PROJECTS",
113114 &[
124125 "comment.created",
125126 "deployment.succeeded",
126127 "deployment.failed",
128+ "deployment_status.created",
127129 "package.published",
128130 "package.deleted",
129131 "package.version_deleted",
330332 assert!(routed("SUBSCRIBER_AGENTS", "workspace.member_joined"));
331333 assert!(!routed("SUBSCRIBER_AGENTS", "comment.created"));
332334 assert!(!routed("SUBSCRIBER_AGENTS", "workspace.updated"));
335+ assert!(routed("SUBSCRIBER_PROJECTS", "deployment_status.created"));
336+ assert!(!routed("SUBSCRIBER_PROJECTS", "deployment.created"));
333337 // Every subscriber follows what moves or removes a repository.
334338 for (binding, _) in ROUTES {
335339 for kind in LIFECYCLE {
+2−0
960960 byRepo: (repoId) => call("by_repo", { repoId }),
961961 create: (actor, workspace, input) => call("create", { actor, workspace, input }),
962962 update: (actor, workspace, slug, changes) => call("update", { actor, workspace, slug, changes }),
963+ setProductionImage: (actor, workspace, slug, change) => call("set_production_image", { actor, workspace, slug, change }),
964+ externalProductions: () => call("external_productions", {}),
963965 deploymentsChanged: (projectId, enabled) => call("deployments_changed", { projectId, enabled }),
964966 shortcuts: (workspace, viewer) => call("shortcuts", { workspace, viewer }),
965967 pin: (actor, workspace, slug, position) => call("pin", { actor, workspace, slug, position: position ?? null }),
+38−0
5151 runs: ProjectRuns | null;
5252 /** Where production is when it runs elsewhere; null when nobody has given one. */
5353 productionUrl: string | null;
54+ /**
55+ * An image of production someone uploaded, shown on its overview when
56+ * there is no screenshot, or instead of one when `preferred`. Null when
57+ * none was uploaded.
58+ */
59+ productionImage: ProductionImage | null;
5460 /** What detection decides, whatever the setting is, to show beside it. */
5561 detected: { kind: ProjectKind; reason: KindReason };
5662 /** The ecosystem its files say it publishes to, for how to publish; null when none says. */
102108 custom: ProjectLink[];
103109 };
104110
111+/**
112+ * An uploaded image of a project's production. `file` is the SHA-256 of
113+ * its bytes: it is served from the usercontent origin at
114+ * `/production-images/<file>`.
115+ */
116+export type ProductionImage = { file: string; contentType: string; preferred: boolean };
117+
118+/** The largest production image taken, in bytes. */
119+export const MAX_PRODUCTION_IMAGE_BYTES = 5 * 1024 * 1024;
120+
121+/** The types a production image may be, read from its bytes. */
122+export const PRODUCTION_IMAGE_TYPES: readonly string[] = ["image/png", "image/jpeg", "image/webp"];
123+
124+/**
125+ * A change to a project's production image. `image` is the new one's bytes
126+ * in base64, or null to remove it (and with it the preference); left out,
127+ * it stays. `preferred` shows it instead of a screenshot.
128+ */
129+export type ProductionImageChange = { image?: string | null; preferred?: boolean };
130+
105131 /** How many links of its own a project keeps besides its homepage and docs. */
106132 export const MAX_PROJECT_LINKS = 10;
107133 /** The longest link label. */
169195 * is refused: they are turned off first.
170196 */
171197 update(actor: User, workspace: string, slug: string, changes: ProjectChanges): Promise<Result<Project>>;
198+ /**
199+ * Uploads, replaces or removes its production image, or says whether it
200+ * is shown instead of a screenshot. Whoever may change the project's
201+ * settings, as for its production address.
202+ */
203+ setProductionImage(actor: User, workspace: string, slug: string, change: ProductionImageChange): Promise<Result<Project>>;
204+ /**
205+ * For the screenshot service's daily refresh: every production address
206+ * of a project deployed elsewhere that shows a screenshot (not an
207+ * uploaded image instead), once each.
208+ */
209+ externalProductions(): Promise<string[]>;
172210 /** For deployments: Deployments were turned on or off for the project. */
173211 deploymentsChanged(projectId: string, enabled: boolean): Promise<void>;
174212 /** A person's pinned and recent projects in a workspace. Empty for anyone else. */
+106−23
11 /**
22 * Taking and keeping production screenshots with Browser Rendering.
33 *
4− * A screenshot is a few seconds of browser time: Workers Paid includes 10
5− * browser hours a month and charges $0.09 an hour after that, so one costs
6− * about $0.0001, and each production deploy takes at most one (plus a retry
7− * after `RETRY_AFTER_MS` if it failed). That is small enough not to meter.
4+ * A screenshot is a few seconds of browser time (at most `SETTLE_MS` and
5+ * the shot itself): Workers Paid includes 10 browser hours a month and
6+ * charges $0.09 an hour after that, so one costs about $0.0001-$0.0003.
7+ * Each production deploy on g1t.page takes at most one, plus a retry after
8+ * `RETRY_AFTER_MS` if it failed. A production deployed elsewhere takes one
9+ * when its address is set or changed and when a production deployment is
10+ * reported for it, and otherwise at most one a day, from the daily cron
11+ * (at most `EXTERNAL_PER_RUN` a run): a hundred such projects come to
12+ * about 3,000 shots and 4 to 10 browser hours a month. A project that
13+ * shows an uploaded image instead is not visited at all. That is small
14+ * enough not to meter.
815 */
916 import puppeteer from "@cloudflare/puppeteer";
1017
11−import { SETTLE_MS, type ShotRequest, VIEWPORT, attemptKey, expired, isCurrent, shotKey, shouldAttempt } from "./screenshot.ts";
18+import {
19+ type ExternalShot,
20+ SETTLE_MS,
21+ type ShotRequest,
22+ VIEWPORT,
23+ attemptKey,
24+ dayOf,
25+ dueForRefresh,
26+ expired,
27+ externalAttemptKey,
28+ externalShotKey,
29+ isCurrent,
30+ isFresh,
31+ parseExternalShot,
32+ shotKey,
33+ shouldAttempt,
34+} from "./screenshot.ts";
1235
1336 export type ShotEnv = {
1437 BROWSER: Fetcher;
1740
1841 export type Shot = { body: ArrayBuffer; contentType: string; commit: string; capturedAt: string };
1942
20−/** The kept screenshot, whatever commit it shows. */
21−export async function stored(env: ShotEnv, host: string): Promise<Shot | null> {
22− const object = await env.SCREENSHOTS.get(shotKey(host));
43+/** Where one screenshot is kept and its attempts noted, and the page it shows. */
44+type Target = { key: string; attempt: string; url: string; host: string; commit: string };
45+
46+async function read(env: ShotEnv, key: string): Promise<Shot | null> {
47+ const object = await env.SCREENSHOTS.get(key);
2348 if (!object) return null;
2449 return {
2550 body: await object.arrayBuffer(),
2954 };
3055 }
3156
32−/**
33− * Takes the screenshot of `commit`, unless one was tried for it moments
34− * ago. Returns it, or null when it was not taken.
35− */
36−export async function take(env: ShotEnv, { host, commit }: ShotRequest): Promise<Shot | null> {
37− const marker = await env.SCREENSHOTS.head(attemptKey(host));
38− if (!shouldAttempt(marker?.customMetadata, commit, Date.now())) return null;
57+/** The kept screenshot, whatever commit it shows. */
58+export function stored(env: ShotEnv, host: string): Promise<Shot | null> {
59+ return read(env, shotKey(host));
60+}
61+
62+/** The kept screenshot of a production deployed elsewhere, by its host. */
63+export function storedExternal(env: ShotEnv, host: string): Promise<Shot | null> {
64+ return read(env, externalShotKey(host));
65+}
66+
67+/** Takes the target's screenshot, unless one was tried for its commit moments ago. */
68+async function shoot(env: ShotEnv, target: Target): Promise<Shot | null> {
69+ const marker = await env.SCREENSHOTS.head(target.attempt);
70+ if (!shouldAttempt(marker?.customMetadata, target.commit, Date.now())) return null;
3971 const at = new Date().toISOString();
40− await env.SCREENSHOTS.put(attemptKey(host), "", { customMetadata: { commit, at } });
72+ await env.SCREENSHOTS.put(target.attempt, "", { customMetadata: { commit: target.commit, at } });
4173
4274 let browser: Awaited<ReturnType<typeof puppeteer.launch>> | null = null;
4375 try {
4577 const page = await browser.newPage();
4678 await page.setViewport(VIEWPORT);
4779 // A page that keeps the network busy is taken as it is when time is up.
48− await page.goto(`https://${host}/`, { waitUntil: "networkidle2", timeout: SETTLE_MS }).catch((error: unknown) => {
49− console.warn("og: page did not settle", host, String(error));
80+ await page.goto(target.url, { waitUntil: "networkidle2", timeout: SETTLE_MS }).catch((error: unknown) => {
81+ console.warn("og: page did not settle", target.host, String(error));
5082 });
5183 const image = (await page.screenshot({ type: "jpeg", quality: 80 })) as Uint8Array;
5284 const body = image.buffer.slice(image.byteOffset, image.byteOffset + image.byteLength) as ArrayBuffer;
5385 const capturedAt = new Date().toISOString();
54− await env.SCREENSHOTS.put(shotKey(host), body, {
86+ await env.SCREENSHOTS.put(target.key, body, {
5587 httpMetadata: { contentType: "image/jpeg" },
56− customMetadata: { commit, capturedAt },
88+ customMetadata: { commit: target.commit, capturedAt },
5789 });
58− return { body, contentType: "image/jpeg", commit, capturedAt };
90+ return { body, contentType: "image/jpeg", commit: target.commit, capturedAt };
5991 } catch (error) {
60− console.error("og: screenshot failed", host, error);
92+ console.error("og: screenshot failed", target.host, error);
6193 return null;
6294 } finally {
6395 await browser?.close().catch(() => undefined);
6496 }
6597 }
6698
99+/**
100+ * Takes the screenshot of `commit` of an app on g1t.page, unless one was
101+ * tried for it moments ago. Returns it, or null when it was not taken.
102+ */
103+export function take(env: ShotEnv, { host, commit }: ShotRequest): Promise<Shot | null> {
104+ return shoot(env, { key: shotKey(host), attempt: attemptKey(host), url: `https://${host}/`, host, commit });
105+}
106+
107+/** Takes the screenshot of a production deployed elsewhere, at its address. */
108+export function takeExternal(env: ShotEnv, { host, url, commit }: ExternalShot): Promise<Shot | null> {
109+ return shoot(env, { key: externalShotKey(host), attempt: externalAttemptKey(host), url, host, commit });
110+}
111+
67112 /** The screenshot of the request's deploy, taken now if it has not been; else the last one kept. */
68113 export async function screenshotOf(env: ShotEnv, request: ShotRequest): Promise<Shot | null> {
69114 const kept = await stored(env, request.host);
72117 }
73118
74119 /**
120+ * The screenshot of a production deployed elsewhere: the one kept, with
121+ * `stale` when it should be taken again (older than a day, or than the
122+ * deploy the request names), which the caller does in the background. With
123+ * none kept, it is taken now.
124+ */
125+export async function externalOf(env: ShotEnv, request: ExternalShot, now = Date.now()): Promise<{ shot: Shot | null; stale: boolean }> {
126+ const kept = await storedExternal(env, request.host);
127+ if (kept) return { shot: kept, stale: !isFresh(kept, now, request.since) };
128+ return { shot: await takeExternal(env, request), stale: false };
129+}
130+
131+/**
132+ * The daily refresh of productions deployed elsewhere: each address whose
133+ * screenshot is missing or more than a day old is taken again, the
134+ * stalest first, one at a time, at most `EXTERNAL_PER_RUN`. Addresses that
135+ * are not public are skipped. Returns how many were taken.
136+ */
137+export async function refreshExternal(env: ShotEnv, urls: readonly string[], now = Date.now()): Promise<number> {
138+ const day = dayOf(now);
139+ const byHost = new Map<string, ExternalShot>();
140+ for (const url of urls) {
141+ const request = parseExternalShot({ url, commit: day });
142+ if (request && !byHost.has(request.host)) byHost.set(request.host, request);
143+ }
144+ const targets = await Promise.all(
145+ [...byHost.values()].map(async (request) => {
146+ const head = await env.SCREENSHOTS.head(externalShotKey(request.host)).catch(() => null);
147+ return { request, capturedAt: head ? (head.customMetadata?.capturedAt ?? head.uploaded.toISOString()) : null };
148+ }),
149+ );
150+ let taken = 0;
151+ for (const { request } of dueForRefresh(targets, now)) {
152+ if (await takeExternal(env, request)) taken += 1;
153+ }
154+ return taken;
155+}
156+
157+/**
75158 * Deletes screenshots and attempt notes older than `KEEP_DAYS`: apps that
76159 * moved or were removed leave theirs behind, and a live app's is taken
77160 * again the next time its page asks. Returns how many went.
78161 */
79162 export async function sweep(env: ShotEnv, now = Date.now()): Promise<number> {
80163 let removed = 0;
81− for (const prefix of ["production/", "attempts/"]) {
164+ for (const prefix of ["production/", "external/", "attempts/"]) {
82165 let cursor: string | undefined;
83166 do {
84167 const page = await env.SCREENSHOTS.list({ prefix, cursor, limit: 500 });
+43−5
1313 * a card shows does, so a new logo or an edited title gets a new address.
1414 *
1515 * Also the `Screenshots` entrypoint, reached only through service
16− * bindings: a screenshot of each project's production, per deploy (see
16+ * bindings: a screenshot of each project's production, per deploy on
17+ * g1t.page, and of a production deployed elsewhere at its address (see
1718 * `capture.ts`).
1819 */
1920 import { WorkerEntrypoint } from "cloudflare:workers";
3334 import mono500 from "./fonts/ibm-plex-mono-500.ttf";
3435 import { cardPng } from "./render.ts";
3536 import { cacheKey, drawnKey } from "./cache.ts";
36−import { type Shot, screenshotOf, sweep, take } from "./capture.ts";
37+import { type Shot, externalOf, refreshExternal, screenshotOf, sweep, take, takeExternal } from "./capture.ts";
3738 import { pausedCard } from "./paused.ts";
38−import { parseShot } from "./screenshot.ts";
39+import { parseExternalShot, parseShot } from "./screenshot.ts";
3940 import { BRAND, type Card, cardPath, docsCard, resolve } from "./resolve.ts";
4041
4142 interface Env {
4950 BILLING?: ServiceBinding;
5051 /** Browser Rendering, for production screenshots. */
5152 BROWSER: Fetcher;
52− /** Production screenshots, by app hostname. */
53+ /** Production screenshots, by app hostname; a production deployed elsewhere's under `external/`. */
5354 SCREENSHOTS: R2Bucket;
5455 /** Cards drawn per address (RATE_LIMITS in packages/contracts). Absent: no limit. */
5556 OG_RENDER_LIMIT?: RateLimitBinding;
7879 if (!request) return null;
7980 return screenshotOf(this.env, request);
8081 }
82+
83+ /**
84+ * Takes the screenshot of a production deployed elsewhere,
85+ * `{ url, commit }` (`commit` a deploy's commit or a UTC day), in the
86+ * background. Called by projects when the address is set or changed and
87+ * when a production deployment is reported. False for an address that is
88+ * not public.
89+ */
90+ async captureExternal(input: unknown): Promise<boolean> {
91+ const request = parseExternalShot(input);
92+ if (!request) return false;
93+ this.ctx.waitUntil(takeExternal(this.env, request));
94+ return true;
95+ }
96+
97+ /**
98+ * The kept screenshot of a production deployed elsewhere, `{ url,
99+ * commit, since? }`, taken now when there is none. One more than a day
100+ * old, or older than `since`, is returned as it is and taken again in
101+ * the background.
102+ */
103+ async externalImage(input: unknown): Promise<Shot | null> {
104+ const request = parseExternalShot(input);
105+ if (!request) return null;
106+ const { shot, stale } = await externalOf(this.env, request);
107+ if (stale) this.ctx.waitUntil(takeExternal(this.env, request));
108+ return shot;
109+ }
81110 }
82111
83112 /*
107136 const NO_STORE = "no-store";
108137
109138 export default {
110− /** Once a day: old screenshots out (capture.ts `sweep`). */
139+ /**
140+ * Once a day: old screenshots out (capture.ts `sweep`), and productions
141+ * deployed elsewhere taken again when theirs is a day old
142+ * (`refreshExternal`).
143+ */
111144 async scheduled(_event: ScheduledController, env: Env): Promise<void> {
112145 const removed = await sweep(env).catch((error) => (console.error("og: sweep failed", error), 0));
113146 if (removed) console.log(`og: removed ${removed} old screenshots`);
147+ const urls = await projectsClient(env.PROJECTS)
148+ .externalProductions()
149+ .catch((error) => (console.error("og: could not list productions deployed elsewhere", error), [] as string[]));
150+ const taken = await refreshExternal(env, urls).catch((error) => (console.error("og: refresh failed", error), 0));
151+ if (taken) console.log(`og: took ${taken} screenshots of productions deployed elsewhere`);
114152 },
115153
116154 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
+119−1
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import { KEEP_DAYS, RETRY_AFTER_MS, attemptKey, expired, isCurrent, parseShot, shotKey, shouldAttempt } from "./screenshot.ts";
4+import {
5+ EXTERNAL_MAX_AGE_MS,
6+ KEEP_DAYS,
7+ RETRY_AFTER_MS,
8+ attemptKey,
9+ dayOf,
10+ dueForRefresh,
11+ expired,
12+ externalAttemptKey,
13+ externalShotKey,
14+ isCurrent,
15+ isFresh,
16+ isPublicHost,
17+ parseExternalShot,
18+ parseShot,
19+ shotKey,
20+ shouldAttempt,
21+} from "./screenshot.ts";
22+
23+test("an external production is visited only at a public address", () => {
24+ for (const host of ["g1t.sh", "www.g1t.sh", "example.com", "app.example.co.uk", "8.8.8.8", "acme-web.vercel.app"]) {
25+ assert.ok(isPublicHost(host), host);
26+ }
27+ for (const host of [
28+ "localhost",
29+ "app.localhost",
30+ "printer.local",
31+ "db.internal",
32+ "intranet",
33+ "127.0.0.1",
34+ "10.1.2.3",
35+ "172.16.0.1",
36+ "172.31.255.255",
37+ "192.168.1.1",
38+ "169.254.169.254",
39+ "100.64.0.1",
40+ "0.0.0.0",
41+ "224.0.0.1",
42+ "999.1.1.1",
43+ "2130706433",
44+ "0x7f.0.0.1",
45+ "[::1]",
46+ "::1",
47+ "fe80::1",
48+ // g1t's own services, but g1t.sh itself.
49+ "api.g1t.sh",
50+ "og.g1t.sh",
51+ "sudo.g1t.sh",
52+ "g1tusercontent.com",
53+ "x.g1tusercontent.com",
54+ "web-acme.g1t.page",
55+ "g1t.page",
56+ ]) {
57+ assert.equal(isPublicHost(host), false, host);
58+ }
59+});
60+
61+test("an external request names a public http(s) address and a commit or a day", () => {
62+ const commit = "abc1234def5678abc1234def5678abc1234def56";
63+ assert.deepEqual(parseExternalShot({ url: "https://G1T.sh/#top", commit }), { host: "g1t.sh", url: "https://g1t.sh/", commit });
64+ assert.deepEqual(parseExternalShot({ url: "http://example.com/app?x=1", commit: "2026-10-11", since: "2026-10-11T00:00:00Z" }), {
65+ host: "example.com",
66+ url: "http://example.com/app?x=1",
67+ commit: "2026-10-11",
68+ since: "2026-10-11T00:00:00Z",
69+ });
70+ for (const url of [
71+ "ftp://example.com/",
72+ "file:///etc/passwd",
73+ "javascript:alert(1)",
74+ "https://user:pass@example.com/",
75+ "https://example.com:8443/",
76+ "http://127.0.0.1/",
77+ "http://169.254.169.254/latest/meta-data",
78+ "https://api.g1t.sh/",
79+ "https://web-acme.g1t.page/",
80+ "not a url",
81+ ]) {
82+ assert.equal(parseExternalShot({ url, commit }), null, url);
83+ }
84+ assert.equal(parseExternalShot({ url: "https://example.com/", commit: "main" }), null);
85+ assert.equal(parseExternalShot({ url: "https://example.com/", commit: "2026-13-45" }), null);
86+ assert.equal(parseExternalShot({ url: "https://example.com/" }), null);
87+ assert.equal(parseExternalShot(null), null);
88+});
89+
90+test("external screenshots are kept apart, by host", () => {
91+ assert.equal(externalShotKey("g1t.sh"), "external/g1t.sh.jpg");
92+ assert.equal(externalAttemptKey("g1t.sh"), "attempts/external/g1t.sh");
93+ assert.equal(dayOf(Date.parse("2026-10-11T23:59:00Z")), "2026-10-11");
94+});
95+
96+test("an external screenshot stands for a day, and not from before the deploy", () => {
97+ const now = Date.parse("2026-10-11T12:00:00Z");
98+ const kept = { capturedAt: "2026-10-11T06:00:00Z" };
99+ assert.ok(isFresh(kept, now));
100+ assert.ok(isFresh(kept, now, "2026-10-11T05:00:00Z"));
101+ assert.ok(!isFresh(kept, now, "2026-10-11T07:00:00Z"));
102+ assert.ok(!isFresh({ capturedAt: new Date(now - EXTERNAL_MAX_AGE_MS).toISOString() }, now));
103+ assert.ok(!isFresh(null, now));
104+});
105+
106+test("the daily refresh takes the missing and the stalest first, up to its limit", () => {
107+ const now = Date.parse("2026-10-11T12:00:00Z");
108+ const targets = [
109+ { name: "fresh", capturedAt: "2026-10-11T06:00:00Z" },
110+ { name: "two days", capturedAt: "2026-10-09T06:00:00Z" },
111+ { name: "missing", capturedAt: null },
112+ { name: "a week", capturedAt: "2026-10-04T06:00:00Z" },
113+ ];
114+ assert.deepEqual(
115+ dueForRefresh(targets, now).map((t) => t.name),
116+ ["missing", "a week", "two days"],
117+ );
118+ assert.deepEqual(
119+ dueForRefresh(targets, now, 1).map((t) => t.name),
120+ ["missing"],
121+ );
122+});
5123
6124 const commit = "abc1234def5678abc1234def5678abc1234def56";
7125
+142−3
11 /**
22 * Screenshots of a project's production: one per deploy, kept in R2 under
3− * the app's g1t.page hostname, with the commit it shows. Only apps on
4− * g1t.page are ever visited, and only by hostname: a custom domain serves
5− * the same app.
3+ * the app's g1t.page hostname, with the commit it shows. An app on
4+ * g1t.page is visited only by hostname: a custom domain serves the same
5+ * app. A project deployed elsewhere is visited at its production address,
6+ * when that is a public one (below).
67 */
78
89 export const DEPLOYMENTS_DOMAIN = "g1t.page";
7172 const at = Date.parse(last.at);
7273 return !Number.isFinite(at) || now - at >= RETRY_AFTER_MS;
7374 }
75+
76+/*
77+ * Production deployed elsewhere: a project that runs on its own pipeline,
78+ * at the address it was given (or the one its reported production
79+ * deployment names). Its screenshot is kept apart from g1t.page apps',
80+ * under `external/<host>.jpg`, by the address's host. With no g1t deploy
81+ * to say when it changed, it is taken when the address is set or changed,
82+ * when a production deployment is reported for it, and otherwise at most
83+ * once a day (index.ts's cron). Its `commit` is the reported deploy's
84+ * commit, or the UTC day it was taken for (`YYYY-MM-DD`).
85+ */
86+
87+/** A kept screenshot of an external production older than this is taken again. */
88+export const EXTERNAL_MAX_AGE_MS = 24 * 60 * 60 * 1000;
89+/** External productions the daily cron takes at most, the stalest first; the rest wait a day. */
90+export const EXTERNAL_PER_RUN = 40;
91+
92+/** An external production: the address to visit, keyed by its host. */
93+export type ExternalShot = ShotRequest & { url: string };
94+
95+/** Where an external production's screenshot is kept. */
96+export function externalShotKey(host: string): string {
97+ return `external/${host}.jpg`;
98+}
99+
100+/** Where the last attempt at an external production's screenshot is noted. */
101+export function externalAttemptKey(host: string): string {
102+ return `attempts/external/${host}`;
103+}
104+
105+/** The UTC day of `now`, as an external screenshot's `commit` when no deploy names one. */
106+export function dayOf(now: number): string {
107+ return new Date(now).toISOString().slice(0, 10);
108+}
109+
110+/**
111+ * Hosts g1t serves itself and never visits as someone's production: its
112+ * own services (any name under g1t.sh but g1t.sh itself), the usercontent
113+ * origin, and g1t.page, whose apps are taken per deploy instead.
114+ */
115+const OWN_DOMAINS = ["g1t.sh", "g1tusercontent.com", DEPLOYMENTS_DOMAIN];
116+/** g1t's own site is a production like any other. */
117+const OWN_ALLOWED = new Set(["g1t.sh", "www.g1t.sh"]);
118+/** Names that only resolve on a private network, or never on the internet. */
119+const PRIVATE_SUFFIXES = [".localhost", ".local", ".internal", ".intranet", ".lan", ".home", ".corp", ".test", ".invalid", ".example", ".onion", ".arpa"];
120+
121+/** Whether a dotted-quad address is not a public one; null when the host is not one. */
122+function privateIPv4(host: string): boolean | null {
123+ const parts = host.split(".");
124+ if (parts.length !== 4 || !parts.every((part) => /^\d{1,3}$/.test(part))) return null;
125+ if (parts.some((part) => Number(part) > 255)) return true;
126+ const [a, b] = parts.map(Number) as [number, number, number, number];
127+ return (
128+ a === 0 ||
129+ a === 10 ||
130+ a === 127 ||
131+ (a === 100 && b >= 64 && b <= 127) ||
132+ (a === 169 && b === 254) ||
133+ (a === 172 && b >= 16 && b <= 31) ||
134+ (a === 192 && b === 168) ||
135+ (a === 192 && b === 0) ||
136+ (a === 198 && (b === 18 || b === 19)) ||
137+ a >= 224
138+ );
139+}
140+
141+/**
142+ * Whether a host is a public name or address on the internet: no
143+ * loopback, private, link-local, shared or reserved address, no name that
144+ * only means something on a private network, and none of g1t's own
145+ * services but g1t.sh. A name is not resolved here: Browser Rendering's
146+ * browser runs on the public internet and cannot reach a private network
147+ * whatever a name resolves to.
148+ */
149+export function isPublicHost(input: string): boolean {
150+ const host = input.toLowerCase().replace(/\.$/, "");
151+ if (!host || host.length > 253) return false;
152+ // An IPv6 literal: none is let through. A site is visited by its name.
153+ if (host.includes(":") || host.startsWith("[")) return false;
154+ const v4 = privateIPv4(host);
155+ if (v4 !== null) return !v4;
156+ // An integer, octal or hexadecimal form of an address.
157+ if (/^(0x[0-9a-f]+|\d+)(\.(0x[0-9a-f]+|\d+))*$/.test(host)) return false;
158+ if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/.test(host)) return false;
159+ if (host === "localhost" || PRIVATE_SUFFIXES.some((suffix) => host.endsWith(suffix))) return false;
160+ if (OWN_ALLOWED.has(host)) return true;
161+ return !OWN_DOMAINS.some((own) => host === own || host.endsWith(`.${own}`));
162+}
163+
164+/**
165+ * The request, if it names a public http(s) address on its usual port and
166+ * either a commit or a UTC day. The address keeps its path and query,
167+ * without its fragment; one with a user or password is refused.
168+ */
169+export function parseExternalShot(input: unknown): ExternalShot | null {
170+ if (!input || typeof input !== "object") return null;
171+ const { url, commit, since } = input as Record<string, unknown>;
172+ if (typeof url !== "string" || typeof commit !== "string" || url.length > 2048) return null;
173+ let parsed: URL;
174+ try {
175+ parsed = new URL(url);
176+ } catch {
177+ return null;
178+ }
179+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null;
180+ if (parsed.username || parsed.password || parsed.port) return null;
181+ if (!isPublicHost(parsed.hostname)) return null;
182+ const version = commit.toLowerCase();
183+ const day = /^\d{4}-\d{2}-\d{2}$/.test(version) && Number.isFinite(Date.parse(version));
184+ if (!/^[0-9a-f]{7,64}$/.test(version) && !day) return null;
185+ parsed.hash = "";
186+ const shot: ExternalShot = { host: parsed.hostname.toLowerCase().replace(/\.$/, ""), url: parsed.toString(), commit: version };
187+ if (typeof since === "string" && Number.isFinite(Date.parse(since))) shot.since = since;
188+ return shot;
189+}
190+
191+/**
192+ * Whether a kept external screenshot still stands for the address: taken
193+ * within `EXTERNAL_MAX_AGE_MS`, and after `since` (the reported deploy).
194+ */
195+export function isFresh(kept: { capturedAt: string } | null | undefined, now: number, since?: string): boolean {
196+ if (!kept) return false;
197+ const at = Date.parse(kept.capturedAt);
198+ if (!Number.isFinite(at) || now - at >= EXTERNAL_MAX_AGE_MS) return false;
199+ return !since || !Number.isFinite(Date.parse(since)) || at >= Date.parse(since);
200+}
201+
202+/**
203+ * The external productions the daily cron takes: those whose screenshot
204+ * is missing or not fresh, the stalest first, at most `limit`.
205+ */
206+export function dueForRefresh<T extends { capturedAt: string | null }>(targets: readonly T[], now: number, limit = EXTERNAL_PER_RUN): T[] {
207+ const age = (target: T) => (target.capturedAt ? Date.parse(target.capturedAt) || 0 : 0);
208+ return targets
209+ .filter((target) => !isFresh(target.capturedAt ? { capturedAt: target.capturedAt } : null, now))
210+ .sort((a, b) => age(a) - age(b))
211+ .slice(0, limit);
212+}
+6−2
2121 // Screenshots of each project's production, one per deploy, taken by
2222 // Browser Rendering and kept in R2. Reached only through the
2323 // `Screenshots` entrypoint: deployments asks for one when production
24− // goes live, and the site reads it for a project's overview.
24+ // goes live, projects when a production deployed elsewhere gets an
25+ // address or reports a deploy, and the site reads it for a project's
26+ // overview.
2527 "browser": { "binding": "BROWSER" },
2628 // Made by `npx wrangler r2 bucket create g1t-screenshots`.
2729 "r2_buckets": [{ "binding": "SCREENSHOTS", "bucket_name": "g1t-screenshots" }],
28− // Once a day, screenshots older than 30 days are deleted (src/capture.ts).
30+ // Once a day, screenshots older than 30 days are deleted, and each
31+ // production deployed elsewhere whose screenshot is a day old is taken
32+ // again (src/capture.ts).
2933 "triggers": { "crons": ["37 5 * * *"] },
3034 "services": [
3135 { "binding": "IDENTITY", "service": "g1t-identity" },
+7−0
1+-- An uploaded image of a project's production (src/production-image.ts):
2+-- the SHA-256 of its bytes, kept in the avatars namespace under
3+-- `production/<sha256>` and served from the usercontent origin; the type
4+-- read from its bytes; and whether it is shown instead of a screenshot.
5+ALTER TABLE projects ADD COLUMN production_image TEXT;
6+ALTER TABLE projects ADD COLUMN production_image_type TEXT;
7+ALTER TABLE projects ADD COLUMN production_image_preferred INTEGER NOT NULL DEFAULT 0;
+130−1
3333 import { MAX_PINS, MAX_RECENT, MAX_VISITS, placePin, recentAfterPins, reorderPins } from "./pins";
3434 import { renameStatements } from "./rename";
3535 import { moveStatements, slugOf, strandedQuery } from "./transfer";
36+import { checkProductionImage, productionImageKey, productionImageOf, sha256, today, wantsScreenshot } from "./production-image";
3637
3738 import {
3839 can,
5657 type NewProject,
5758 type Project,
5859 type ProjectChanges,
60+ type ProductionImageChange,
5961 type ProjectKind,
6062 type ProjectEcosystem,
6163 type ProjectShortcuts,
7274 IDENTITY: ServiceBinding;
7375 PACKAGES: ServiceBinding;
7476 DEPLOYMENTS: ServiceBinding;
77+ /** The avatars namespace: uploaded production images, under `production/<sha256>`, which the usercontent origin serves. */
78+ AVATARS: KVNamespace;
79+ /**
80+ * The og service's `Screenshots` entrypoint: a screenshot of a production
81+ * deployed elsewhere, taken when its address is set or changed and when
82+ * a production deployment is reported. Absent: none is asked for.
83+ */
84+ SCREENSHOTS?: { captureExternal(input: { url: string; commit: string }): Promise<boolean> };
7585 };
7686
7787 type Row = {
102112 kind_setting?: string | null;
103113 runs_setting?: string | null;
104114 production_url?: string | null;
115+ /** An uploaded image of production, by its SHA-256 (migrations/0012_production_image.sql). */
116+ production_image?: string | null;
117+ production_image_type?: string | null;
118+ production_image_preferred?: number | null;
105119 /** Its own homepage; null follows `repo_website`. */
106120 homepage?: string | null;
107121 repo_website?: string | null;
170184 kindReason: reason,
171185 runs,
172186 productionUrl: row.production_url ?? null,
187+ productionImage: productionImageOf(row),
173188 detected: detectedKind(facts),
174189 ecosystem: (row.detected_ecosystem as ProjectEcosystem | null) ?? null,
175190 links: projectLinks(row),
487502 let saved = (await this.db.prepare("SELECT * FROM projects WHERE id = ?").bind(row.id).first<Row>())!;
488503 // Another root has other files: read them again.
489504 if (rootDir !== row.root_dir) saved = (await this.detect({ ...saved, detected_commit: null }).catch(() => null)) ?? saved;
490− return ok(toProject(saved));
505+ const project = toProject(saved);
506+ // Production elsewhere given an address, or a new one: its screenshot, now.
507+ const was = toProject(row);
508+ if (wantsScreenshot(project) && (project.productionUrl !== was.productionUrl || was.runs !== "elsewhere")) {
509+ this.screenshot(project.productionUrl!, today());
510+ }
511+ return ok(project);
512+ }
513+
514+ /**
515+ * Asks og for a screenshot of a production deployed elsewhere (og takes
516+ * it in the background). Never fails: the overview and the daily
517+ * refresh take it later anyway.
518+ */
519+ private async capture(url: string, commit: string): Promise<void> {
520+ await this.env.SCREENSHOTS?.captureExternal({ url, commit }).catch((error) => console.warn("could not ask for a screenshot", url, error));
521+ }
522+
523+ /** The same, after the answer is sent. */
524+ private screenshot(url: string, commit: string): void {
525+ this.defer(this.capture(url, commit));
491526 }
492527
528+ async setProductionImage(a: {
529+ actor: User;
530+ workspace: string;
531+ slug: string;
532+ change: ProductionImageChange;
533+ }): Promise<Result<Project>> {
534+ const change = a.change ?? {};
535+ const row = await this.db
536+ .prepare("SELECT * FROM projects WHERE workspace = ? AND slug = ? AND repo_deleted_at IS NULL")
537+ .bind(a.workspace.toLowerCase(), a.slug.toLowerCase())
538+ .first<Row>();
539+ if (!row) return fail("not_found", "There is no such project.");
540+ // Whoever may change its production address.
541+ const allowed = this.changeable(row, a.actor, NEEDS.update);
542+ if (!allowed.ok) return allowed;
543+ let file = row.production_image ?? null;
544+ let type = row.production_image_type ?? null;
545+ let preferred = !!row.production_image_preferred;
546+ if (change.image === null) {
547+ file = null;
548+ type = null;
549+ preferred = false;
550+ } else if (change.image !== undefined) {
551+ const checked = checkProductionImage(change.image);
552+ if (!checked.ok) return fail("invalid", checked.message);
553+ file = await sha256(checked.bytes);
554+ type = checked.contentType;
555+ // Kept by its hash, so the same image stored twice is one file; its
556+ // type is the one read from its bytes (usercontent serves only that).
557+ await this.env.AVATARS.put(productionImageKey(file), checked.bytes, { metadata: { contentType: type } });
558+ }
559+ if (change.preferred !== undefined) {
560+ if (change.preferred && !file) return fail("invalid", "Upload an image first.");
561+ preferred = !!change.preferred;
562+ }
563+ await this.db
564+ .prepare("UPDATE projects SET production_image = ?, production_image_type = ?, production_image_preferred = ?, updated_at = ? WHERE id = ?")
565+ .bind(file, type, preferred ? 1 : 0, now(), row.id)
566+ .run();
567+ // The image it replaced goes, unless another project shows it too.
568+ const old = row.production_image;
569+ if (old && old !== file) {
570+ const used = await this.db.prepare("SELECT 1 FROM projects WHERE production_image = ? LIMIT 1").bind(old).first();
571+ if (!used) this.defer(this.env.AVATARS.delete(productionImageKey(old)).catch((error) => console.warn("could not delete an image", error)));
572+ }
573+ const saved = (await this.db.prepare("SELECT * FROM projects WHERE id = ?").bind(row.id).first<Row>())!;
574+ const project = toProject(saved);
575+ // No longer shown instead of a screenshot: one is taken, if none is fresh.
576+ if (row.production_image_preferred && !preferred && wantsScreenshot(project)) this.screenshot(project.productionUrl!, today());
577+ return ok(project);
578+ }
579+
580+ /** Every production address deployed elsewhere that shows a screenshot, once each. */
581+ async externalProductions(): Promise<string[]> {
582+ const rows = await this.db
583+ .prepare(
584+ `SELECT * FROM projects WHERE production_url IS NOT NULL AND repo_deleted_at IS NULL
585+ AND (runs_setting IS NULL OR runs_setting = 'elsewhere') AND production_image_preferred = 0`,
586+ )
587+ .all<Row>();
588+ const urls = rows.results.map(toProject).filter(wantsScreenshot).map((project) => project.productionUrl!);
589+ return [...new Set(urls)];
590+ }
591+
592+ /**
593+ * A production deployment reported from outside g1t.page succeeded: a
594+ * screenshot of each of its repository's projects deployed elsewhere,
595+ * at the address the deployment gives, else the project's own.
596+ */
597+ private async productionReported(data: Extract<G1tEvent, { type: "deployment_status.created" }>["data"]): Promise<void> {
598+ const { deployment, deploymentStatus } = data;
599+ if (deploymentStatus.state !== "success" || !deployment.production_environment || deployment.source === "g1t_page") return;
600+ const rows = await this.db
601+ .prepare("SELECT * FROM projects WHERE repo_id = ? AND repo_deleted_at IS NULL ORDER BY is_primary DESC")
602+ .bind(data.repoId)
603+ .all<Row>();
604+ // The repository's own project first: the overview shows the reported
605+ // production on it, as running elsewhere when nobody has said otherwise.
606+ const project = rows.results
607+ .map(toProject)
608+ .find((one) => one.runs === "elsewhere" || (one.runs === null && one.kind === "app"));
609+ if (!project || project.productionImage?.preferred) return;
610+ const url = deploymentStatus.environment_url ?? deployment.environment_url ?? project.productionUrl;
611+ if (url) await this.capture(url, deployment.sha);
612+ }
613+
493614 async publicLinks(a: { repos: { namespace: string; name: string }[] }): Promise<Record<string, string>> {
494615 const keys = [...new Set((Array.isArray(a.repos) ? a.repos : []).slice(0, 100).map((r) => `${r.namespace}/${r.name}`.toLowerCase()))];
495616 if (keys.length === 0) return {};
776897 const repoId = event.repoId ?? ("repoId" in event.data ? (event.data as { repoId?: string }).repoId : undefined);
777898 if (repoId) await this.active(repoId, event.time, event.type === "git.push");
778899 }
900+ if (event.type === "deployment_status.created") {
901+ await this.productionReported(event.data);
902+ return;
903+ }
779904 if (event.type === "workspace.renamed") {
780905 const current = await currentWorkspaceSlug(this.env.IDENTITY, event.data);
781906 const statements = renameStatements(staleSlugs(event.data, current), current);
9421067 return Response.json(await service.create(args));
9431068 case "update":
9441069 return Response.json(await service.update(args));
1070+ case "set_production_image":
1071+ return Response.json(await service.setProductionImage(args));
1072+ case "external_productions":
1073+ return Response.json(await service.externalProductions());
9451074 case "deployments_changed":
9461075 await service.deploymentsChanged(args);
9471076 return Response.json(null);
+70−0
1+import { test } from "node:test";
2+import assert from "node:assert/strict";
3+
4+import {
5+ MAX_PRODUCTION_IMAGE_BYTES,
6+ checkProductionImage,
7+ productionImageKey,
8+ productionImageOf,
9+ sha256,
10+ sniffProductionImage,
11+ today,
12+ wantsScreenshot,
13+} from "./production-image.ts";
14+
15+const PNG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0];
16+const JPEG = [0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0, 0, 0, 0, 0];
17+const WEBP = [...Buffer.from("RIFF"), 0, 0, 0, 0, ...Buffer.from("WEBP")];
18+const GIF = [...Buffer.from("GIF89a"), 0, 0, 0, 0, 0, 0];
19+const base64 = (bytes: number[] | Uint8Array) => Buffer.from(bytes).toString("base64");
20+
21+test("a production image is a PNG, JPEG or WebP, by its bytes", () => {
22+ assert.equal(sniffProductionImage(Uint8Array.from(PNG)), "image/png");
23+ assert.equal(sniffProductionImage(Uint8Array.from(JPEG)), "image/jpeg");
24+ assert.equal(sniffProductionImage(Uint8Array.from(WEBP)), "image/webp");
25+ assert.equal(sniffProductionImage(Uint8Array.from(GIF)), null);
26+ assert.equal(sniffProductionImage(new TextEncoder().encode("<svg xmlns='http://www.w3.org/2000/svg'/>")), null);
27+});
28+
29+test("an upload is checked: present, at most 5 MB, and an image", () => {
30+ const png = checkProductionImage(base64(PNG));
31+ assert.ok(png.ok);
32+ assert.equal(png.ok && png.contentType, "image/png");
33+ assert.deepEqual(checkProductionImage(""), { ok: false, message: "Choose a PNG, JPEG or WebP image." });
34+ assert.deepEqual(checkProductionImage(null), { ok: false, message: "Choose a PNG, JPEG or WebP image." });
35+ assert.deepEqual(checkProductionImage("%%%"), { ok: false, message: "Choose a PNG, JPEG or WebP image." });
36+ assert.deepEqual(checkProductionImage(base64(GIF)), { ok: false, message: "Use a PNG, JPEG or WebP image." });
37+ const big = new Uint8Array(MAX_PRODUCTION_IMAGE_BYTES + 1);
38+ big.set(PNG);
39+ assert.deepEqual(checkProductionImage(base64(big)), { ok: false, message: "Use an image of at most 5 MB." });
40+});
41+
42+test("an image is kept by its hash, apart from avatars", async () => {
43+ const file = await sha256(new TextEncoder().encode("abc"));
44+ assert.equal(file, "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
45+ assert.equal(productionImageKey(file), `production/${file}`);
46+});
47+
48+test("the stored image, and whether it is preferred", () => {
49+ assert.equal(productionImageOf({}), null);
50+ assert.deepEqual(productionImageOf({ production_image: "ab", production_image_type: "image/webp", production_image_preferred: 1 }), {
51+ file: "ab",
52+ contentType: "image/webp",
53+ preferred: true,
54+ });
55+ assert.equal(productionImageOf({ production_image: "ab", production_image_preferred: 0 })?.preferred, false);
56+});
57+
58+test("only production elsewhere, at an address and not shown as an upload, is visited", () => {
59+ const elsewhere = { runs: "elsewhere", productionUrl: "https://g1t.sh", productionImage: null };
60+ assert.ok(wantsScreenshot(elsewhere));
61+ assert.ok(wantsScreenshot({ ...elsewhere, productionImage: { preferred: false } }));
62+ assert.ok(!wantsScreenshot({ ...elsewhere, productionImage: { preferred: true } }));
63+ assert.ok(!wantsScreenshot({ ...elsewhere, productionUrl: null }));
64+ assert.ok(!wantsScreenshot({ ...elsewhere, runs: "g1t" }));
65+ assert.ok(!wantsScreenshot({ ...elsewhere, runs: null }));
66+});
67+
68+test("the day a screenshot is versioned by", () => {
69+ assert.equal(today(Date.parse("2026-10-11T23:30:00Z")), "2026-10-11");
70+});
+92−0
Loading the diff…
+11−2
Loading the diff…