Public: anyone can read this; the crew can change it.

Bring in Jay Moran's build pins and Iterations, ported onto main with his name on them #9050

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/9041-bring-in-jay-moran-s-build-pins-and-iter → main 4 commits fork 1 thread · 1 unresolved

  • Merged. mason/9041-bring-in-jay-moran-s-build-pins-and-iter was merged into main as 72da119.

Review

Sign in to review this change.
23 files changed +1259 −49
docs/APPS.md +5 −1
@@ -1016,7 +1016,11 @@ someone tries.
10161016 Apps are served from the forge's port by `Host`. With `FORGE_APP_DOMAIN`
10171017 (default: the forge's own host), production is `<slug>.<domain>` and the
10181018 preview of pull 12 is `<slug>--12.<domain>`. `--` cannot occur inside a slug,
1019 so the two never collide. Where only single-label hosts are routable (a
1019 so the two never collide. A build preview, one commit a writer asked to see
1020 now (`POST /api/v1/repos/:owner/:name/builds/preview` with `{ "sha": … }`), is
1021 `<slug>--b<sha7>.<domain>` (`build-<sha7>`): held in memory, at most three per
1022 app, the oldest giving way, gone with the process; a slug may not be `b` and
1023 seven hex digits. Where only single-label hosts are routable (a
10201024 wildcard certificate covers one label), give the attachment itself:
10211025 `FORGE_APP_DOMAIN=--forge.example` serves `notes--forge.example` and
10221026 `notes--12--forge.example`; a value that starts with a separator is used
docs/features/build-preview.md added +38
@@ -0,0 +1,38 @@
1 # Build preview
2
3 Look at any one commit of an app, now, without pinning it or opening a pull.
4 It runs at `<slug>--b<sha7>.<domain>` with a copy of production's data, the
5 way a pull's preview does. A writer who is a person may ask (verified email;
6 never an agent; not on a mirror). Nothing is stored: the forge holds at most
7 three per app in memory, the oldest giving way to a newer one, and a restart
8 removes them.
9
10 ## From the terminal
11
12 Open the app's **Deploys** screen (palette, then the app's deploys). Press
13 `b`, type the commit's full 40-character id, press Enter. A line names the
14 address, or says why not (the same words as below). The key is offered only to
15 a writer who is a person.
16
17 ## From the API
18
19 ```
20 POST /api/v1/repos/<owner>/<name>/builds/preview
21 authorization: Bearer <token with rw scope>
22 {"sha": "<40-character commit id>"}
23 ```
24
25 201 answers `{ "env": "build-1a2b3c4", "url": "…" }`. 422 is a refusal
26 (`forbidden`, `unverified`, `mirrored`, `not_a_commit`, `no_runtime`,
27 `not_found`); 429 is `rate_limited`.
28
29 ## The limit
30
31 Each new build can tear down the oldest and starts a deploy, so one person may
32 ask 20 times per app in ten minutes (`BUILD_ASKS_MAX`,
33 `BUILD_ASKS_WINDOW_MS` in `actions/build_preview.ts`), counted by their
34 identity. Asking again for a build already running costs the same ask and
35 redeploys nothing.
36
37 Try it: on a preview, `POST` a commit of the app to the route above and open
38 the returned `url`.
src/actions/apps.ts +7 −1
@@ -24,6 +24,7 @@ import { grantUrl, originFor, sideForViewer, sideReader } from "../apps/proxy.ts
2424 import { plainLine } from "../apps/side.ts";
2525 import { scaffoldFiles } from "../apps/scaffold.ts";
2626 import {
27 envBuild,
2728 isEnvName,
2829 previewEnv,
2930 type EnvName,
@@ -720,7 +721,7 @@ export async function redeployEnv(
720721 if (!isEnvName(env)) return err("no_such_env");
721722 const runtime = appsRuntime(app);
722723 if (runtime === undefined) return err("no_runtime");
723 const commit = await commitFor(app, repo, env);
724 const commit = await commitFor(app, repo, record, env);
724725 if (commit === undefined) return err("nothing_to_deploy");
725726 return ok(
726727 runtime.requestDeploy(
@@ -761,11 +762,16 @@ export async function stopEnv(
761762 async function commitFor(
762763 app: App,
763764 repo: Repo,
765 record: AppRecord,
764766 env: EnvName,
765767 ): Promise<string | undefined> {
766768 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
767769 if (env === "production")
768770 return resolveOid(dir, `refs/heads/${repo.defaultBranch}`);
771 // A build preview names its commit only by its short sha: the commit it
772 // serves is the one the runtime holds for it, while it is held.
773 if (envBuild(env) !== undefined)
774 return appsRuntime(app)?.envStatus(record, env)?.commitOid || undefined;
769775 const pull = getPull(app.db, repo.id, Number(env.slice(5)));
770776 if (!pull || pull.state !== "open") return undefined;
771777 const at = await pullDir(app.db, app.reposRoot, repo, pull);
src/actions/build_preview.ts added +129
@@ -0,0 +1,129 @@
1 /**

Terminal door missing: add a src/tui/ screen and docs/features/build-preview.md. Also add a rate limit on asks per actor (identity, not a header).

2 * Preview now: any commit of an app, served for a while at
3 * `<slug>--b<sha7>` with a copy of production's data, the way a preview is.
4 * It is how a writer looks at a build without pinning it or opening a pull.
5 *
6 * Nothing of it is stored: no ref, no row. The runtime holds it in memory
7 * (`AppsRuntime.previewBuild`), at most `BUILD_PREVIEWS_MAX` per app, the
8 * oldest giving way to a newer one, and a forge coming back removes it.
9 * The ask is announced on the outbox (`app.build_preview`).
10 *
11 * Each new build can tear down the oldest and starts a deploy, so one person
12 * may ask `BUILD_ASKS_MAX` times per app in `BUILD_ASKS_WINDOW_MS`, counted
13 * in memory by their identity (a restart forgives, as `core/ratelimit.ts`).
14 */
15
16 import type { App } from "../app.ts";
17 import { buildEnv, type EnvName } from "../apps/data.ts";
18 import { appsRuntime } from "../apps/runtime.ts";
19 import { getAppByRepo } from "../core/apps.ts";
20 import type { Repo } from "../core/repos.ts";
21 import type { Run } from "../core/runs.ts";
22 import type { User } from "../core/users.ts";
23 import { isSafeOid } from "../git/guard.ts";
24 import { readerFor } from "../git/read.ts";
25 import { repoDir } from "../git/repos.ts";
26 import { err, ok, type Result } from "../kernel/result.ts";
27 import { isMirror } from "../origins/write-through.ts";
28 import { requireWriter, type AccessError } from "./access.ts";
29 import { requireVerified } from "./verify.ts";
30
31 export type PreviewBuildError =
32 | AccessError
33 | "unverified"
34 /** The repository mirrors an origin: preview a build in a fork of it. */
35 | "mirrored"
36 /** `oid` is not a full object id naming a commit in this repository. */
37 | "not_a_commit"
38 /** Apps are off on this forge: nothing can serve the build. */
39 | "no_runtime"
40 /** This person asked too often for this app; try again later. */
41 | "rate_limited";
42
43 export const BUILD_ASKS_MAX = 20;
44 export const BUILD_ASKS_WINDOW_MS = 10 * 60_000;
45
46 const asks = new WeakMap<App, Map<string, number[]>>();
47
48 /** Record an ask by `actorId` of `appId`; false when it is over the limit. */
49 function allowed(app: App, actorId: string, appId: string, now = Date.now()): boolean {
50 let byKey = asks.get(app);
51 if (byKey === undefined) asks.set(app, (byKey = new Map()));
52 const key = `${actorId}:${appId}`;
53 const recent = (byKey.get(key) ?? []).filter((t) => now - t < BUILD_ASKS_WINDOW_MS);
54 if (recent.length >= BUILD_ASKS_MAX) {
55 byKey.set(key, recent);
56 return false;
57 }
58 recent.push(now);
59 byKey.set(key, recent);
60 return true;
61 }
62
63 /** A refusal as a person reads it, on the terminal and anywhere else that says it in words. */
64 export function previewBuildRefusalText(error: string): string {
65 switch (error) {
66 case "unauthorized":
67 case "forbidden":
68 return "Only a person with write access can preview a build.";
69 case "unverified":
70 return "Verify your email first: open the link the forge mailed you, or ask for another from your account settings.";
71 case "mirrored":
72 return "This repository mirrors another: preview a build in a fork of it.";
73 case "not_a_commit":
74 return "That is not a commit in this repository (give the full 40-character id).";
75 case "no_runtime":
76 return "Nothing is serving apps on this forge.";
77 case "rate_limited":
78 return "You have asked for a lot of builds; wait a few minutes.";
79 case "not_found":
80 return "This repository has no app.";
81 default:
82 return "That could not be done.";
83 }
84 }
85
86 export interface BuildPreview {
87 readonly env: EnvName;
88 readonly url: string;
89 /** The deploy that brings it up, or undefined when it is already here. */
90 readonly run: Run | undefined;
91 }
92
93 /**
94 * Serve the commit `oid` (a full id in this repository) at its own short-
95 * lived environment. A verified person who can write the repository may
96 * ask, never an agent. Asking for one already here redeploys nothing.
97 */
98 export async function previewBuild(
99 app: App,
100 actor: User | undefined,
101 repo: Repo,
102 oid: string,
103 ): Promise<Result<BuildPreview, PreviewBuildError>> {
104 const writer = requireWriter(app, actor, repo);
105 if (!writer.ok) return writer;
106 // The crew proposes; a person chooses what is shown (docs/APPS.md rule 1).
107 if (writer.value.kind === "agent") return err("forbidden");
108 const proven = requireVerified(app, writer.value);
109 if (!proven.ok) return proven;
110 if (isMirror(app, repo)) return err("mirrored");
111 const record = getAppByRepo(app.db, repo.id);
112 if (record === undefined) return err("not_found");
113 if (!isSafeOid(oid)) return err("not_a_commit");
114 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
115 const stat = await readerFor(dir).stat(oid);
116 if (!stat.ok || stat.value.type !== "commit") return err("not_a_commit");
117 const runtime = appsRuntime(app);
118 if (runtime === undefined) return err("no_runtime");
119 if (!allowed(app, writer.value.id, record.id)) return err("rate_limited");
120
121 app.bus.append({
122 kind: "app.build_preview",
123 repoId: repo.id,
124 actorId: writer.value.id,
125 payload: { env: buildEnv(oid), oid },
126 });
127 const { env, run } = await runtime.previewBuild(record, repo, oid, writer.value.id);
128 return ok({ env, url: runtime.urlFor(record, env), run });
129 }
src/actions/env_words.ts +4 −2
@@ -4,12 +4,14 @@
44 * terminal's deploys screen, from here.
55 */
66
7 import { envPull, type EnvName } from "../apps/data.ts";
7 import { envBuild, envPull, type EnvName } from "../apps/data.ts";
88 import type { Run } from "../core/runs.ts";
99 import { VERIFY_FIRST } from "./verify_words.ts";
1010
11 /** `production` or `preview of #12`, as a phrase that fits in a sentence. */
11 /** `production`, `preview of #12` or `build 1a2b3c4`, as a phrase that fits in a sentence. */
1212 export function envLabel(env: EnvName): string {
13 const build = envBuild(env);
14 if (build !== undefined) return `build ${build}`;
1315 const number = envPull(env);
1416 return number === undefined ? "production" : `preview of #${number}`;
1517 }
src/actions/tell.ts +4 −1
@@ -117,7 +117,10 @@ export async function tell(
117117 for (let k = 1; k <= variants; k++) {
118118 const note = many ? `Variant ${k} of ${variants}.` : "";
119119 const origin = from === null ? "" : `Told from [${from}](${from})`;
120 const rest = text === firstLine ? "" : text;
120 // The body is what follows the title; a title cut short keeps the
121 // whole text in the body, so nothing said is lost.
122 const rest =
123 title === firstLine ? text.slice(text.indexOf(firstLine) + firstLine.length).trim() : text;
121124 const opened = openIssue(app, actor, repo, {
122125 title,
123126 body: [rest, note, origin].filter((s) => s !== "").join("\n\n"),
src/apps/data.ts +51 −6
@@ -34,18 +34,63 @@ import {
3434 import { join } from "node:path";
3535 import { defect } from "../kernel/error.ts";
3636
37 /** `production`, or `pull-<n>` for the preview of pull `n`. */
38 export type EnvName = "production" | `pull-${number}`;
37 /**
38 * `production`, `pull-<n>` for the preview of pull `n`, or `build-<sha7>`
39 * for a build preview: one commit a writer asked to see now, short-lived.
40 */
41 export type EnvName = "production" | `pull-${number}` | `build-${string}`;
3942
4043 export const previewEnv = (pullNumber: number): EnvName => `pull-${pullNumber}`;
4144
42 export function isEnvName(value: string): value is EnvName {
43 return value === "production" || /^pull-[1-9]\d*$/.test(value);
45 /** A build preview's name: the commit's short sha, `build-1a2b3c4`. */
46 export const buildEnv = (oid: string): EnvName => `build-${oid.slice(0, 7).toLowerCase()}`;
47
48 /** The short sha a build preview serves; `undefined` for any other environment. */
49 export function envBuild(env: EnvName): string | undefined {
50 return /^build-[0-9a-f]{7}$/.test(env) ? env.slice(6) : undefined;
4451 }
4552
46 /** The pull a preview environment belongs to, or `undefined` for production. */
53 export function isEnvName(value: string): value is EnvName {
54 return (
55 value === "production" ||
56 /^pull-[1-9]\d*$/.test(value) ||
57 /^build-[0-9a-f]{7}$/.test(value)
58 );
59 }
60
61 /** The pull a preview environment belongs to; `undefined` for production and a build. */
4762 export function envPull(env: EnvName): number | undefined {
48 return env === "production" ? undefined : Number(env.slice(5));
63 return env.startsWith("pull-") ? Number(env.slice(5)) : undefined;
64 }
65
66 /**
67 * What follows the slug in an environment's hostname label and its ssh
68 * login: nothing for production, `--<n>` for a preview, or
69 * `--b<sha7>` for a build preview. One rule builds both, and
70 * `parseAppHost` reads it back.
71 */
72 export function envHostLabel(slug: string, env: EnvName): string {
73 if (env === "production") return slug;
74 const build = envBuild(env);
75 if (build !== undefined) return `${slug}--b${build}`;
76 return `${slug}--${env.slice(5)}`;
77 }
78
79 /**
80 * A host label's tail read back as a build preview: `b1a2b3c4` →
81 * `build-1a2b3c4`. Slugs of that shape are reserved (`core/apps.ts`), so
82 * the tail is never an app.
83 */
84 export function buildEnvFromLabel(tail: string): EnvName | undefined {
85 return /^b[0-9a-f]{7}$/.test(tail) ? `build-${tail.slice(1)}` : undefined;
86 }
87
88 /** An environment as a person reads its name: production, the preview of #n, or the build at <sha7>. */
89 export function envWords(env: EnvName): string {
90 if (env === "production") return "production";
91 const build = envBuild(env);
92 if (build !== undefined) return `the build at ${build}`;
93 return `the preview of #${env.slice(5)}`;
4994 }
5095
5196 export const appsRoot = (dataDir: string): string => join(dataDir, "apps");
src/apps/proxy.ts +28 −10
@@ -1,6 +1,7 @@
11 /**
22 * Apps on the forge's own port, by `Host`. `<slug>.<domain>` is production,
3 * `<slug>--<n>.<domain>` the preview of pull `n`; the request is fetched
3 * `<slug>--<n>.<domain>` the preview of pull `n`, `<slug>--b<sha7>.<domain>`
4 * a build preview; the request is fetched
45 * from the deployment's loopback port and streamed back. A websocket
56 * handshake is the same request until the app accepts it — then the forge
67 * holds one socket to the browser and one to the app and carries frames
@@ -38,7 +39,13 @@ import { verifyToken } from "../core/tokens.ts";
3839 import { getUserById, isActive, type User } from "../core/users.ts";
3940 import { err, ok, type Result } from "../kernel/result.ts";
4041 import { loadKey } from "../kernel/secret.ts";
41 import { previewEnv, type EnvName } from "./data.ts";
42 import {
43 buildEnvFromLabel,
44 envBuild,
45 envWords,
46 previewEnv,
47 type EnvName,
48 } from "./data.ts";
4249 import type { AppsRuntime, EnvStatus } from "./runtime.ts";
4350 import { crewNow, tellLink } from "./crew.ts";
4451 import { sideOf, type Side } from "./side.ts";
@@ -97,11 +104,17 @@ export function parseAppHost(
97104 if (label === "" || label.includes(".")) return undefined;
98105 const parts = label.split("--");
99106 if (parts.some((part) => part === "")) return undefined;
100 // The app of ours is the tail of the label: `slug`, or `slug--N` for the
101 // preview of pull N. Whatever comes before it belongs to the app's own
102 // world — a forge hosted here has apps of its own, at `<theirs>--<ours>`
103 // — and travels through untouched in `Host`, for that app to route.
107 // The app of ours is the tail of the label: `slug`, `slug--N` for the
108 // preview of pull N, or `slug--b<sha7>` for a build preview (`b` and seven
109 // hex digits is no slug: `isSlug`). Whatever comes before it belongs to the
110 // app's own world — a forge hosted here has apps of its own, at
111 // `<theirs>--<ours>` — and travels through untouched in `Host`, for that
112 // app to route.
104113 const last = parts.at(-1)!;
114 const build = parts.length >= 2 ? buildEnvFromLabel(last) : undefined;
115 if (build !== undefined) {
116 return { slug: parts.at(-2)!, env: build, nested: parts.length > 2 };
117 }
105118 if (parts.length >= 2 && /^\d+$/.test(last)) {
106119 if (!/^[1-9]\d*$/.test(last)) return undefined;
107120 return {
@@ -1628,9 +1641,9 @@ export function unavailable(
16281641 ): Response {
16291642 const name = target.app.slug;
16301643 const where =
1631 target.env === "production"
1632 ? "production"
1633 : `the preview of pull #${target.env.slice(5)}`;
1644 target.env.startsWith("pull-")
1645 ? `the preview of pull #${target.env.slice(5)}`
1646 : envWords(target.env);
16341647 const state = status?.state ?? "stopped";
16351648 const deploying = state === "deploying";
16361649 // A preview is gone when its pull is closed, merged or no longer exists:
@@ -1649,7 +1662,12 @@ export function unavailable(
16491662 // environment keeps its 502.
16501663 const absent = preview && !gone && status === undefined;
16511664 const words = servingWords(status, preview ? (gone ? "closed" : "open") : null);
1652 const message = reason ?? words.says;
1665 const build = envBuild(target.env) !== undefined;
1666 const message =
1667 reason ??
1668 (build && gone
1669 ? "This build preview is gone: it was taken down, and its data with it. Ask for it again from the app's builds."
1670 : words.says);
16531671 const links = discoveryLinks(runtime, target);
16541672 const report = links.length > 0;
16551673 const runLink =
src/apps/runtime.ts +65 −18
@@ -81,6 +81,9 @@ import { DRAIN_BUDGET_MS, drainPort } from "./handoff.ts";
8181 import type { DomainResolver } from "./domains.ts";
8282 import {
8383 appsRoot,
84 buildEnv,
85 envBuild,
86 envHostLabel,
8487 ensureEnvData,
8588 envDataDir,
8689 envDir,
@@ -353,8 +356,13 @@ interface Environment {
353356 lastErrorCommitOid?: string | undefined;
354357 /** Crash restarts in the current window, for backing off. */
355358 restarts: number[];
359 /** A build preview's place in the order build previews were started, oldest lowest. */
360 buildOrder?: number | undefined;
356361 }
357362
363 /** How many build previews (`build-<sha7>`) one app runs at once; a newer one takes the oldest's place. */
364 export const BUILD_PREVIEWS_MAX = 3;
365
358366 /** The deploy reasons that mean a person touched the change. */
359367 const TOUCH_REASONS: readonly string[] = [
360368 "push",
@@ -396,13 +404,13 @@ export function envsFor(
396404 return runtime.status(record);
397405 }
398406
399 /** Production first, then previews by pull number — a number, so `pull-10` follows `pull-9`. */
400 const byEnv = (a: EnvStatus, b: EnvStatus): number =>
401 a.env === "production"
402 ? -1
403 : b.env === "production"
404 ? 1
405 : (envPull(a.env) ?? 0) - (envPull(b.env) ?? 0);
407 /**
408 * Production first, then build previews, then previews by
409 * pull number — a number, not a string, so `pull-10` follows `pull-9`.
410 */
411 const envRank = (env: EnvName): number =>
412 env === "production" ? -1 : (envPull(env) ?? 0);
413 const byEnv = (a: EnvStatus, b: EnvStatus): number => envRank(a.env) - envRank(b.env);
406414
407415 export class AppsRuntime {
408416 private readonly envs = new Map<string, Environment>();
@@ -415,6 +423,8 @@ export class AppsRuntime {
415423 private unsubscribe: (() => void) | undefined;
416424 private sweeper: ReturnType<typeof setInterval> | undefined;
417425 private stopped = false;
426 /** Counts build previews as they start, so the oldest is the lowest. */
427 private buildCount = 0;
418428
419429 constructor(
420430 readonly app: App,
@@ -437,8 +447,7 @@ export class AppsRuntime {
437447 // ---- addressing ----
438448
439449 hostFor(slug: string, env: EnvName): string {
440 const label = env === "production" ? slug : `${slug}--${env.slice(5)}`;
441 return `${label}${appHostSuffix(this.options.domain)}`;
450 return `${envHostLabel(slug, env)}${appHostSuffix(this.options.domain)}`;
442451 }
443452
444453 /**
@@ -446,9 +455,7 @@ export class AppsRuntime {
446455 * login, the forge is the host — or undefined when SSH is off.
447456 */
448457 sshFor(record: AppRecord, env: EnvName): string | undefined {
449 const login =
450 env === "production" ? record.slug : `${record.slug}--${env.slice(5)}`;
451 return sshCommand(this.app, login);
458 return sshCommand(this.app, envHostLabel(record.slug, env));
452459 }
453460
454461 /**
@@ -509,8 +516,8 @@ export class AppsRuntime {
509516 for (const state of this.envs.values()) {
510517 if (state.app.id === record.id) out.push(this.describe(state));
511518 }
512 // Production first, then previews by pull number — a number, not a
513 // string, so `pull-10` follows `pull-9`.
519 // Production first, then build previews, then previews
520 // by pull number (`envRank`).
514521 return out.sort(byEnv);
515522 }
516523
@@ -743,8 +750,8 @@ export class AppsRuntime {
743750
744751 /**
745752 * Make the environments match the repository: production at the default
746 * branch, a preview per open pull, and nothing for pulls that closed
747 * while the forge was away.
753 * branch, a preview per open pull, and nothing for pulls that closed while the
754 * forge was away.
748755 */
749756 async reconcileApp(
750757 record: AppRecord,
@@ -757,6 +764,11 @@ export class AppsRuntime {
757764 if (tip !== undefined && only !== "preview")
758765 this.requestDeploy(record, repo, "production", tip, reason);
759766 const open = new Set<EnvName>(["production"]);
767 // A build preview lives only in memory: one this forge is running stays,
768 // and one an earlier life left on disk goes (`previewBuild`).
769 for (const state of this.envs.values())
770 if (state.app.id === record.id && envBuild(state.env) !== undefined)
771 open.add(state.env);
760772 for (const pull of listPulls(this.app.db, repo.id, { sealed: true,
761773 state: "open",
762774 limit: 200,
@@ -1025,7 +1037,7 @@ export class AppsRuntime {
10251037 return state.deploying.run;
10261038 }
10271039 const issue =
1028 env === "production" ? undefined : this.pullIssueId(repo, env);
1040 envPull(env) === undefined ? undefined : this.pullIssueId(repo, env);
10291041 const run = createRun(this.app.db, {
10301042 repoId: repo.id,
10311043 kind: "deploy",
@@ -1053,6 +1065,38 @@ export class AppsRuntime {
10531065 return run;
10541066 }
10551067
1068 /**
1069 * Serve one commit now, at `build-<sha7>`. Asked again for a build already
1070 * here, it redeploys nothing and hands back the same environment. A new
1071 * one beyond `BUILD_PREVIEWS_MAX` takes the place of the oldest, which is
1072 * torn down with its data. Nothing of it outlives this process: a forge
1073 * coming back removes what a build preview left on disk.
1074 */
1075 async previewBuild(
1076 record: AppRecord,
1077 repo: Repo,
1078 commitOid: string,
1079 actorId?: string,
1080 ): Promise<{ env: EnvName; run: Run | undefined }> {
1081 const env = buildEnv(commitOid);
1082 const k = key(record.id, env);
1083 if (!this.envs.has(k)) {
1084 // Claimed before anything waits, so two asks at once count each other.
1085 this.envFor(record, env).buildOrder = ++this.buildCount;
1086 const builds = [...this.envs.values()]
1087 .filter((s) => s.app.id === record.id && s.buildOrder !== undefined)
1088 .sort((a, b) => b.buildOrder! - a.buildOrder!);
1089 for (const old of builds.slice(BUILD_PREVIEWS_MAX)) {
1090 log.info("build preview replaced", { app: record.slug, env: old.env, by: env });
1091 await this.teardown(record, old.env);
1092 }
1093 }
1094 // Torn down by a newer ask while the old ones went: nothing to deploy.
1095 if (!this.envs.has(k)) return { env, run: undefined };
1096 const run = this.requestDeploy(record, repo, env, commitOid, "build", actorId);
1097 return { env, run };
1098 }
1099
10561100 private pullIssueId(repo: Repo, env: EnvName): string | undefined {
10571101 const pull = getPull(this.app.db, repo.id, Number(env.slice(5)));
10581102 return pull?.id;
@@ -1669,7 +1713,10 @@ export class AppsRuntime {
16691713 env: EnvName,
16701714 commitOid: string,
16711715 ): boolean {
1672 if (env === "production") return true;
1716 // Production is the default branch and a build preview a commit a writer asked to see
1717 // (`actions/build_preview.ts`): each is a writer's choice already.
1718 if (env === "production" || envBuild(env) !== undefined)
1719 return true;
16731720 const number = envPull(env);
16741721 const pull =
16751722 number === undefined ? undefined : getPull(this.app.db, repo.id, number);
src/apps/tty.ts +9 −2
@@ -27,13 +27,18 @@ import { canEnterApp, canRead, canWrite, getRepoById } from "../core/repos.ts";
2727 import type { User } from "../core/users.ts";
2828 import { err, ok, type Result } from "../kernel/result.ts";
2929 import type { SshSession } from "../ssh/server.ts";
30 import { isEnvName, previewEnv, type EnvName } from "./data.ts";
30 import {
31 buildEnvFromLabel,
32 isEnvName,
33 previewEnv,
34 type EnvName,
35 } from "./data.ts";
3136 import { appEnv, signalGroup } from "./process.ts";
3237 import { appsRuntime } from "./runtime.ts";
3338
3439 const CRLF = "\r\n";
3540
36 /** `notes` → production, `notes--12` → the preview of pull 12. */
41 /** `notes` → production, `notes--12` → the preview of pull 12, `notes--b1a2b3c4` → the build at 1a2b3c4. */
3742 export function parseAppLogin(
3843 login: string,
3944 ): { slug: string; env: EnvName } | undefined {
@@ -43,6 +48,8 @@ export function parseAppLogin(
4348 return isSlugLike(name) ? { slug: name, env: "production" } : undefined;
4449 const slug = name.slice(0, at);
4550 const number = name.slice(at + 2);
51 const build = buildEnvFromLabel(number);
52 if (isSlugLike(slug) && build !== undefined) return { slug, env: build };
4653 if (!isSlugLike(slug) || !/^[1-9]\d*$/.test(number)) return undefined;
4754 const env = previewEnv(Number(number));
4855 return isEnvName(env) ? { slug, env } : undefined;
src/contract/openapi/routes/index.ts +1
@@ -248,6 +248,7 @@ export { route as postApiV1ReposOwnerNameAppNotesIdBan } from "./postApiV1ReposO
248248 export { route as postApiV1ReposOwnerNameAppNotesIdHide } from "./postApiV1ReposOwnerNameAppNotesIdHide.ts";
249249 export { route as postApiV1ReposOwnerNameAppNotesIdUnban } from "./postApiV1ReposOwnerNameAppNotesIdUnban.ts";
250250 export { route as postApiV1ReposOwnerNameBranches } from "./postApiV1ReposOwnerNameBranches.ts";
251 export { route as postApiV1ReposOwnerNameBuildsPreview } from "./postApiV1ReposOwnerNameBuildsPreview.ts";
251252 export { route as postApiV1ReposOwnerNameChecksRecheck } from "./postApiV1ReposOwnerNameChecksRecheck.ts";
252253 export { route as postApiV1ReposOwnerNameContents } from "./postApiV1ReposOwnerNameContents.ts";
253254 export { route as postApiV1ReposOwnerNameContribute } from "./postApiV1ReposOwnerNameContribute.ts";
src/contract/openapi/routes/postApiV1ReposOwnerNameBuildsPreview.ts added +11
@@ -0,0 +1,11 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "post",
5 path: `${REPO}/builds/preview`,
6 summary: "Serve one commit `{ sha }` at its own short-lived environment (rw scope, a writer's); nothing is stored; answers 201 with { env, url }",
7 auth: true,
8 body: { sha: "string" },
9 status: 201,
10 reply: { env: "string", url: "string" },
11 };
src/core/apps.ts +14 −2
@@ -45,6 +45,13 @@ const toApp = (r: AppRow): AppRecord => ({
4545 createdAt: r.created_at,
4646 });
4747
48 /**
49 * A slug that is also an environment's suffix: `<slug>--b<sha7>` is a build
50 * preview, so no slug may be `b` and seven hex digits.
51 */
52 const RESERVED_SLUG = /^b[0-9a-f]{7}$/;
53 const reserved = (slug: string): boolean => RESERVED_SLUG.test(slug);
54
4855 /**
4956 * A slug is a DNS label: lowercase letters, digits, single hyphens inside.
5057 * `--` is reserved as the separator before a preview number, so a slug can
@@ -54,7 +61,12 @@ export const SLUG_MAX = 40;
5461 const SLUG = /^[a-z0-9](?:-?[a-z0-9])*$/;
5562
5663 export function isSlug(value: string): boolean {
57 return value.length > 0 && value.length <= SLUG_MAX && SLUG.test(value);
64 return (
65 value.length > 0 &&
66 value.length <= SLUG_MAX &&
67 SLUG.test(value) &&
68 !reserved(value)
69 );
5870 }
5971
6072 /** The slug a repository name suggests: lowercased, runs of junk to one `-`. */
@@ -65,7 +77,7 @@ export function slugFor(name: string): string {
6577 .replace(/^-+|-+$/g, "")
6678 .slice(0, SLUG_MAX)
6779 .replace(/-+$/g, "");
68 return slug === "" ? "app" : slug;
80 return slug === "" ? "app" : reserved(slug) ? `${slug}-app` : slug;
6981 }
7082
7183 export type CreateAppError = "invalid_slug" | "slug_taken" | "already_app";
src/git/branches.ts +58
@@ -357,3 +357,61 @@ export async function startOid(
357357 }
358358 return await resolveOid(dir, from);
359359 }
360
361 export type LeasedRefError =
362 | "invalid_ref"
363 | "invalid_oid"
364 | "ref_moved"
365 | "git_failed";
366
367 /**
368 * Point a full ref (`refs/…`) at `newOid` — or delete it when `newOid` is
369 * `undefined` — only if it still holds `oldOid` (`undefined`: it must not
370 * exist yet). The same lease `createBranch`/`deleteBranch` take, for a ref
371 * outside `refs/heads/`. No hooks run; the caller announces the change.
372 */
373 export async function moveRefLeased(
374 dir: string,
375 ref: string,
376 newOid: string | undefined,
377 oldOid: string | undefined,
378 ): Promise<Result<void, LeasedRefError>> {
379 if (!isSafeRef(ref) || !ref.startsWith("refs/")) return err("invalid_ref");
380 if (
381 (newOid !== undefined && !isSafeOid(newOid)) ||
382 (oldOid !== undefined && !isSafeOid(oldOid))
383 )
384 return err("invalid_oid");
385 if (newOid === undefined && oldOid === undefined) return ok();
386 const args =
387 newOid === undefined
388 ? ["update-ref", "-d", ref, oldOid!]
389 : ["update-ref", ref, newOid, oldOid ?? ""];
390 const moved = await runGitCapture(args, { cwd: dir });
391 if (moved.code !== 0) {
392 return err(
393 /cannot lock|is at .* but expected|already exists|unable to resolve/i.test(
394 moved.stderr,
395 )
396 ? "ref_moved"
397 : "git_failed",
398 );
399 }
400 return ok();
401 }
402
403 /**
404 * The object a full ref names, read exactly: `show-ref --verify` takes no
405 * DWIM, so `refs/demo` never resolves to a branch someone called that.
406 */
407 export async function exactRefOid(
408 dir: string,
409 ref: string,
410 ): Promise<string | undefined> {
411 if (!isSafeRef(ref) || !ref.startsWith("refs/")) return undefined;
412 const read = await runGitCapture(["show-ref", "--verify", "--hash", ref], {
413 cwd: dir,
414 });
415 const oid = read.stdout.trim();
416 return read.code === 0 && isSafeOid(oid) ? oid : undefined;
417 }
src/http/api/app.ts +19
@@ -9,6 +9,7 @@
99 import { readAppMembers, setAppMembersThrough, type AppMembersError } from "../../actions/app_members.ts";
1010 import { appView, appVisitors, deleteAppEnvVar, disableApp, enableApp, listAppEnv, openEnvUrl, redeployEnv, setAppEnvVar, stopEnv, updateAppSettings, type AppSettingsInput } from "../../actions/apps.ts";
1111 import { addAppDomain, checkAppDomain, listAppDomains, removeAppDomain } from "../../actions/domains.ts";
12 import { previewBuild, previewBuildRefusalText } from "../../actions/build_preview.ts";
1213 import { SETTING_WORDS } from "../../actions/setting_words.ts";
1314 import { appsRuntime } from "../../apps/runtime.ts";
1415 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
@@ -292,6 +293,24 @@ export function appRoutes(router: Router<Handler>): void {
292293 },
293294 );
294295
296 // Preview now: serve one commit `{ sha }` at its own short-lived
297 // environment (`actions/build_preview.ts`). Nothing is stored; the answer
298 // names the environment and where it is served.
299 router.add("POST", "/api/v1/repos/:owner/:name/builds/preview", async (ctx) => {
300 const denied = requireScope(ctx, "rw");
301 if (denied) return denied;
302 const repo = loadRepo(ctx);
303 if (!repo) return notFound();
304 const input = await body(ctx.request);
305 const sha = typeof input["sha"] === "string" ? input["sha"] : "";
306 const preview = await previewBuild(ctx.app, ctx.auth.user, repo, sha);
307 if (!preview.ok)
308 return preview.error === "rate_limited"
309 ? json({ error: previewBuildRefusalText(preview.error) }, 429)
310 : respondAccess(preview.error, 422);
311 return json({ env: preview.value.env, url: preview.value.url }, 201);
312 });
313
295314 // The URL to open an environment at. For a private app it carries a
296315 // grant, so the answer is for the caller alone and is not cached.
297316 router.add("GET", "/api/v1/repos/:owner/:name/app/:env/open", (ctx) => {
src/looks/service.ts +9 −2
@@ -30,7 +30,7 @@ import {
3030 } from "node:fs";
3131 import { dirname, join } from "node:path";
3232 import type { App } from "../app.ts";
33 import { isEnvName, previewEnv, type EnvName } from "../apps/data.ts";
33 import { envBuild, envPull, isEnvName, previewEnv, type EnvName } from "../apps/data.ts";
3434 import { signGrant, type GrantWho } from "../apps/proxy.ts";
3535 import { appsRuntime } from "../apps/runtime.ts";
3636 import {
@@ -654,7 +654,14 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle {
654654 }
655655 retakeStrangerThumbs(record);
656656 for (const env of readdirSync(join(root, appId))) {
657 if (!isEnvName(env) || env === "production") continue;
657 if (!isEnvName(env)) continue;
658 // A build preview is gone once the runtime no longer holds it.
659 if (envBuild(env) !== undefined) {
660 if (appsRuntime(app)?.envStatus(record, env) === undefined)
661 forget(appId, env);
662 continue;
663 }
664 if (envPull(env) === undefined) continue;
658665 const pull = getPull(app.db, record.repoId, Number(env.slice(5)));
659666 if (
660667 pull === undefined ||
src/tui/screens/apptty.ts +2 −3
@@ -6,7 +6,7 @@
66 */
77
88 import { runAppTty, ttyRefusalText } from "../../apps/tty.ts";
9 import type { EnvName } from "../../apps/data.ts";
9 import { envHostLabel, type EnvName } from "../../apps/data.ts";
1010 import { getAppByRepo } from "../../core/apps.ts";
1111 import type { Repo } from "../../core/repos.ts";
1212 import type { Ctx } from "../app/app.ts";
@@ -23,8 +23,7 @@ export async function openAppTerminal(
2323 ctx.toast("this repository is not an app", "attn");
2424 return;
2525 }
26 const name =
27 env === "production" ? record.slug : `${record.slug}--${env.slice(5)}`;
26 const name = envHostLabel(record.slug, env);
2827 const ran = await ctx.suspend((host) =>
2928 runAppTty(f.app, host, f.user, { app: record, env }),
3029 );
src/tui/screens/deploys.ts +35
@@ -17,6 +17,10 @@
1717 * each: `#N title · check <word> · <preview address or none>`. A guest sees
1818 * none.
1919 *
20 * A writer who is a person — never an agent — gets `b`: type a commit's
21 * full id and `previewBuild` serves it at `<slug>--b<sha7>`, as the API's
22 * `POST …/builds/preview` does (`docs/features/build-preview.md`).
23 *
2024 * A writer redeploys production with `D` and stops it with `S`, each after a
2125 * one-line confirm, through `redeployEnv` and `stopEnv` — the page's and MCP
2226 * `app_deploy`'s actions — and is told the answer in the page's words
@@ -64,6 +68,7 @@ import {
6468 type AppPreview,
6569 type AppPullLine,
6670 } from "../../actions/apps.ts";
71 import { previewBuild, previewBuildRefusalText } from "../../actions/build_preview.ts";
6772 import { STOP_APP } from "../../web/danger_words.ts";
6873 import { envPhrase } from "../../apps/env_phrase.ts";
6974 import {
@@ -250,6 +255,31 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
250255 const openPreview = (p: AppPreview): void => ctx.push(pullScreen(f, ctx, local(f, repo), p.number));
251256 // The MCP tool's gate, read once: only a writer is offered the knock.
252257 const mayProbe = canWrite(f.db, repo, f.user);
258 // Offered to a person who writes; `previewBuild` gates again, agents included.
259 const mayBuild = mayProbe && f.user !== undefined && f.user.kind !== "agent";
260
261 /** `b`: ask for a commit's id, then serve it as a build preview and say where. */
262 const previewACommit = (): void => {
263 ctx.push(
264 promptScreen(ctx, screen, {
265 title: "Preview a build",
266 label: "Commit id",
267 placeholder: "40 hex characters",
268 hint: "Served at its own address for a while, with a copy of production's data.",
269 onSubmit: (typed) => {
270 const sha = typed.trim().toLowerCase();
271 if (!/^[0-9a-f]{40}$/.test(sha)) return "Give the full 40-character commit id.";
272 runAsync(ctx, async () => {
273 const asked = await previewBuild(f.app, f.user, repo, sha);
274 if (!asked.ok) ctx.toast(previewBuildRefusalText(asked.error), "danger");
275 else ctx.toast(`Previewing ${sha.slice(0, 7)} at ${asked.value.url}`, "success");
276 read();
277 });
278 return undefined;
279 },
280 }),
281 );
282 };
253283
254284 /** Redeploy or stop `env` and say the answer in the page's words; one path for production and previews. */
255285 const act = async (verb: "Redeploy" | "Stop", env: EnvName): Promise<{ text: string; refused: boolean }> => {
@@ -347,6 +377,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
347377 ...(domains !== undefined ? [binding(["n"], "the app's own domains")] : []),
348378 ...(prompts ? [binding(["i"], "prompt source (Instagram)")] : []),
349379 ...(stoppable ? [binding(["X"], STOP_APP.toLowerCase())] : []),
380 ...(mayBuild ? [binding(["b"], "preview a commit")] : []),
350381 ];
351382 },
352383 load: async () => read(),
@@ -494,6 +525,10 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
494525 ctx.push(probeScreen(f, ctx, repo));
495526 return true;
496527 }
528 if (e.key === "b" && mayBuild) {
529 previewACommit();
530 return true;
531 }
497532 if (e.key === "m" && members) {
498533 ctx.push(appMembersScreen(f, ctx, repo));
499534 return true;
src/web/layout.ts +19 −1
@@ -144,6 +144,12 @@ export interface PageOptions {
144144 * nothing else on this page is relaxed.
145145 */
146146 turnstile?: boolean;
147 /**
148 * One more origin this page may frame, as `scheme://host[:port]`: the
149 * display origin a captured page plays on. It loosens `frame-src` for this
150 * page alone; anything that is not a bare origin is ignored.
151 */
152 frameSrc?: string | undefined;
147153 /** Extra palette rows for this page's context (a repo's own tabs). */
148154 palette?: PaletteItem[];
149155 /**
@@ -821,7 +827,7 @@ export function page(options: PageOptions, status = 200): Response {
821827 // The hashed stylesheet and scripts are 'self'; the two inline <style>s
822828 // are named by hash. connect-src 'self' lets the live event stream
823829 // (EventSource) and fragment fetches reach same-origin.
824 "content-security-policy": `default-src 'none'; style-src 'self' ${styleSources}; script-src 'self'${options.turnstile ? ` ${TURNSTILE_ORIGIN}` : ""}; connect-src 'self'${options.turnstile ? ` ${TURNSTILE_ORIGIN}` : ""}; ${options.turnstile ? `frame-src ${TURNSTILE_ORIGIN}; ` : ""}worker-src 'self'; manifest-src 'self'; img-src 'self' data:; ${options.objectSrc ? "object-src 'self'; " : ""}form-action 'self'; base-uri 'none'; frame-ancestors 'none'`,
830 "content-security-policy": `default-src 'none'; style-src 'self' ${styleSources}; script-src 'self'${options.turnstile ? ` ${TURNSTILE_ORIGIN}` : ""}; connect-src 'self'${options.turnstile ? ` ${TURNSTILE_ORIGIN}` : ""}; ${frameSources(options)}worker-src 'self'; manifest-src 'self'; img-src 'self' data:; ${options.objectSrc ? "object-src 'self'; " : ""}form-action 'self'; base-uri 'none'; frame-ancestors 'none'`,
825831 "referrer-policy": "same-origin",
826832 "x-content-type-options": "nosniff",
827833 // Markup is revalidated every time; only the hashed assets it names are
@@ -857,6 +863,18 @@ export function page(options: PageOptions, status = 200): Response {
857863
858864 const TURNSTILE_ORIGIN = "https://challenges.cloudflare.com";
859865
866 /** A bare origin: scheme, host and optional port, nothing that could add a directive. */
867 const BARE_ORIGIN = /^https?:\/\/[a-z0-9.-]+(?::\d{1,5})?$/i;
868
869 /** The `frame-src` directive (with its trailing space), or nothing when the page frames nothing. */
870 export function frameSources(options: Pick<PageOptions, "turnstile" | "frameSrc">): string {
871 const origins = [
872 ...(options.turnstile ? [TURNSTILE_ORIGIN] : []),
873 ...(options.frameSrc !== undefined && BARE_ORIGIN.test(options.frameSrc) ? [options.frameSrc] : []),
874 ];
875 return origins.length === 0 ? "" : `frame-src ${origins.join(" ")}; `;
876 }
877
860878 export interface RedirectOptions {
861879 /** A Set-Cookie value to send alongside (session, theme). */
862880 cookie?: string;
test/apps-build-preview.test.ts added +418
@@ -0,0 +1,418 @@
1 /**
2 * Preview now (`actions/build_preview.ts`): a writer asks for any commit of
3 * an app and it runs at `<slug>--b<sha7>` with a copy of production's data.
4 * Asking twice is one environment, a fourth takes the oldest's place, and a
5 * reader, an agent, a mirror, a repository that is no app and anything that
6 * is not a commit here are refused. The host parser reads the new label back
7 * without losing previews or a hosted forge's own apps.
8 */
9
10 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
11 import { mkdtemp, rm } from "node:fs/promises";
12 import { tmpdir } from "node:os";
13 import { join } from "node:path";
14 import { createApp, type App } from "../src/app.ts";
15 import { enableApp } from "../src/actions/apps.ts";
16 import { BUILD_ASKS_MAX, previewBuild } from "../src/actions/build_preview.ts";
17 import { addToCrew, createAgent } from "../src/actions/crew.ts";
18 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
19 import { buildEnv, envWords } from "../src/apps/data.ts";
20 import { parseAppHost } from "../src/apps/proxy.ts";
21 import { parseAppLogin } from "../src/apps/tty.ts";
22 import { AppsRuntime, BUILD_PREVIEWS_MAX, type EnvStatus } from "../src/apps/runtime.ts";
23 import { isSlug, slugFor, type AppRecord } from "../src/core/apps.ts";
24 import { listEvents } from "../src/core/events.ts";
25 import { createOrigin, linkRepoToOrigin } from "../src/core/origins.ts";
26 import type { Repo } from "../src/core/repos.ts";
27 import { listRuns, type Run } from "../src/core/runs.ts";
28 import { createToken } from "../src/core/tokens.ts";
29 import { createUser, getUserByName, type User } from "../src/core/users.ts";
30 import { repoDir } from "../src/git/repos.ts";
31 import { serve, type Servers } from "../src/http/server.ts";
32 import { gitClientEnv } from "./helpers/git-client.ts";
33
34 const DOMAIN = "apps.test";
35
36 /** Says which commit and environment it is, and what production left in its data. */
37 const SERVER = `import { existsSync, readFileSync, writeFileSync } from "node:fs";
38 import { join } from "node:path";
39 const data = process.env.FORGE_APP_DATA ?? ".";
40 const seed = join(data, "seed.txt");
41 if (process.env.FORGE_APP_ENV === "production" && !existsSync(seed))
42 writeFileSync(seed, "written by production");
43 Bun.serve({
44 port: Number(process.env.PORT ?? "0"),
45 hostname: "127.0.0.1",
46 fetch: () =>
47 new Response(
48 [
49 process.env.FORGE_APP_COMMIT,
50 process.env.FORGE_APP_URL,
51 existsSync(seed) ? readFileSync(seed, "utf8") : "no data",
52 ].join("\\n"),
53 ),
54 });
55 `;
56
57 describe("build preview hosts", () => {
58 test("<slug>--b<sha7> reads back as the build, and the old forms still read", () => {
59 expect(parseAppHost(`show--b1a2b3c4.${DOMAIN}`, DOMAIN)).toEqual({
60 slug: "show",
61 env: "build-1a2b3c4",
62 nested: false,
63 });
64 // Hostnames are case-insensitive; the env name is not.
65 expect(parseAppHost(`Show--B1A2B3C4.${DOMAIN}:3000`, DOMAIN)?.env).toBe("build-1a2b3c4");
66 // A forge hosted in a build preview keeps its own apps in front of ours.
67 expect(parseAppHost(`notes--show--b1a2b3c4.${DOMAIN}`, DOMAIN)).toEqual({
68 slug: "show",
69 env: "build-1a2b3c4",
70 nested: true,
71 });
72 // A guest's build preview under our app is the guest's to route.
73 expect(parseAppHost(`notes--b1a2b3c4--show.${DOMAIN}`, DOMAIN)).toEqual({
74 slug: "show",
75 env: "production",
76 nested: true,
77 });
78 // Not seven hex digits after `b`: not a build, so the tail is a slug.
79 expect(parseAppHost(`show--b1a2b3c.${DOMAIN}`, DOMAIN)?.env).toBe("production");
80 expect(parseAppHost(`show--b1a2b3cz.${DOMAIN}`, DOMAIN)?.env).toBe("production");
81 expect(parseAppHost(`show--b1a2b3c4d.${DOMAIN}`, DOMAIN)?.env).toBe("production");
82 // Previews and production as they were.
83 expect(parseAppHost(`show--3.${DOMAIN}`, DOMAIN)?.env).toBe("pull-3");
84 expect(parseAppHost(`cs--3--lab--14.${DOMAIN}`, DOMAIN)).toEqual({
85 slug: "lab",
86 env: "pull-14",
87 nested: true,
88 });
89 expect(parseAppHost(`show.${DOMAIN}`, DOMAIN)?.env).toBe("production");
90 // The single-label attachment works for builds too.
91 expect(parseAppHost("show--b1a2b3c4--forge.example", "--forge.example")).toEqual({
92 slug: "show",
93 env: "build-1a2b3c4",
94 nested: false,
95 });
96 expect(parseAppLogin("show--b1a2b3c4")).toEqual({ slug: "show", env: "build-1a2b3c4" });
97 expect(buildEnv("1A2B3C4D5E")).toBe("build-1a2b3c4");
98 expect(envWords("build-1a2b3c4")).toBe("the build at 1a2b3c4");
99 });
100
101 test("a build-shaped slug is reserved", () => {
102 expect(isSlug("b1a2b3c4")).toBe(false);
103 expect(slugFor("b1a2b3c4")).toBe("b1a2b3c4-app");
104 expect(isSlug("b1a2b3c")).toBe(true);
105 expect(isSlug("build")).toBe(true);
106 });
107 });
108
109 describe("preview now", () => {
110 let dataDir: string;
111 let app: App;
112 let servers: Servers;
113 let runtime: AppsRuntime;
114 let alice: User;
115 let bob: User;
116 let repo: Repo;
117 let record: AppRecord;
118 const commits: string[] = [];
119 let aliceToken = "";
120 let bobToken = "";
121
122 const viaHost = (host: string, path = "/") =>
123 fetch(`${servers.url}${path}`, { headers: { host }, redirect: "manual" });
124
125 const api = (method: string, path: string, token: string, body?: unknown) =>
126 fetch(`${servers.url}/api/v1${path}`, {
127 method,
128 headers: {
129 authorization: `Bearer ${token}`,
130 "content-type": "application/json",
131 },
132 ...(body === undefined ? {} : { body: JSON.stringify(body) }),
133 });
134
135 async function until<T>(read: () => T | undefined, ms = 60_000): Promise<T> {
136 const deadline = Date.now() + ms;
137 while (Date.now() < deadline) {
138 const value = read();
139 if (value !== undefined) return value;
140 await Bun.sleep(50);
141 }
142 throw new Error("did not happen in time");
143 }
144
145 const settled = (where: (run: Run) => boolean): Promise<Run> =>
146 until(() =>
147 listRuns(app.db, repo.id, { kind: "deploy", limit: 50 }).find(
148 (r) =>
149 where(r) &&
150 (r.state === "ok" || r.state === "failed" || r.state === "cancelled"),
151 ),
152 );
153
154 const builds = (): EnvStatus[] =>
155 runtime.status(record).filter((e) => e.env.startsWith("build-"));
156
157 const bare = () => repoDir(app.reposRoot, repo.ownerName, repo.name);
158
159 beforeAll(async () => {
160 dataDir = await mkdtemp(join(tmpdir(), "forge-build-preview-"));
161 app = createApp({ dataDir, port: 0 });
162 servers = await serve(app);
163 runtime = new AppsRuntime(app, {
164 domain: DOMAIN,
165 scheme: "http",
166 publicPort: servers.port,
167 forgeUrl: servers.url,
168 });
169 await runtime.start();
170 const a = await createUser(app.db, { origin: "seed",
171 trusted: true,
172 username: "alice",
173 email: "[email protected]",
174 password: "a-good-passphrase",
175 adminIfFirst: true,
176 });
177 if (!a.ok) throw new Error(a.error);
178 alice = a.value;
179 const b = await createUser(app.db, { origin: "seed",
180 trusted: true,
181 username: "bob",
182 email: "[email protected]",
183 password: "a-good-passphrase",
184 });
185 if (!b.ok) throw new Error(b.error);
186 bob = b.value;
187 aliceToken = createToken(app.db, alice.id, "test", "rw").token;
188 bobToken = createToken(app.db, bob.id, "test", "rw").token;
189
190 const made = await createRepo(app, alice, { name: "show", visibility: "public" });
191 if (!made.ok) throw new Error(String(made.error));
192 repo = made.value;
193 const c1 = await commitToBranch(app, alice, repo, {
194 message: "a build worth seeing",
195 files: [
196 {
197 path: "package.json",
198 content: JSON.stringify({ name: "show", scripts: { start: "bun server.ts" } }),
199 },
200 { path: "server.ts", content: SERVER },
201 ],
202 });
203 if (!c1.ok) throw new Error(JSON.stringify(c1.error));
204 commits.push(c1.value.commitOid);
205 // More builds on a side branch, so production stays where it is.
206 for (const n of [2, 3, 4, 5]) {
207 const c = await commitToBranch(app, alice, repo, {
208 branch: "side",
209 base: n === 2 ? "main" : "side",
210 message: `build ${n}`,
211 files: [{ path: `NOTE${n}.md`, content: `${n}\n` }],
212 });
213 if (!c.ok) throw new Error(JSON.stringify(c.error));
214 commits.push(c.value.commitOid);
215 }
216 const enabled = enableApp(app, alice, repo, {});
217 if (!enabled.ok) throw new Error(enabled.error);
218 record = enabled.value;
219 const production = await settled((r) => r.env === "production");
220 if (production.state !== "ok") throw new Error(production.summary);
221 }, 90_000);
222
223 afterAll(async () => {
224 await runtime.stop();
225 servers.stop();
226 app.close();
227 await rm(dataDir, { recursive: true, force: true });
228 });
229
230 test("a reader, an agent and nobody are refused", async () => {
231 const sha = commits[1]!;
232 const response = await api("POST", "/repos/alice/show/builds/preview", bobToken, { sha });
233 expect(response.status).toBe(403);
234 const reader = await previewBuild(app, bob, repo, sha);
235 expect(reader.ok ? undefined : reader.error).toBe("forbidden");
236 const anonymous = await previewBuild(app, undefined, repo, sha);
237 expect(anonymous.ok ? undefined : anonymous.error).toBe("unauthorized");
238
239 const made = await createAgent(app, alice, { username: "scout", brief: "I ship." });
240 if (!made.ok) throw new Error(String(made.error));
241 expect(addToCrew(app, alice, repo, "scout").ok).toBe(true);
242 const scout = getUserByName(app.db, "scout")!;
243 const agent = await previewBuild(app, scout, repo, sha);
244 expect(agent.ok ? undefined : agent.error).toBe("forbidden");
245
246 expect(builds()).toEqual([]);
247 expect(listEvents(app.db, { kind: "app.build_preview" })).toEqual([]);
248 });
249
250 test("anything that is not a commit here is refused", async () => {
251 const tree = Bun.spawnSync(["git", "rev-parse", `${commits[0]}^{tree}`], {
252 cwd: bare(),
253 env: gitClientEnv(),
254 })
255 .stdout.toString()
256 .trim();
257 for (const sha of [tree, "0".repeat(40), commits[0]!.slice(0, 7), "--upload-pack=x", ""]) {
258 const response = await api("POST", "/repos/alice/show/builds/preview", aliceToken, { sha });
259 expect(response.status).toBe(422);
260 expect(((await response.json()) as { error: string }).error).toBe("not_a_commit");
261 }
262 expect(builds()).toEqual([]);
263 });
264
265 test("a repository that is no app, and a mirror, are refused", async () => {
266 const plain = await createRepo(app, alice, { name: "plain", visibility: "public" });
267 if (!plain.ok) throw new Error(String(plain.error));
268 const p = await commitToBranch(app, alice, plain.value, {
269 message: "seed",
270 files: [{ path: "README.md", content: "hi\n" }],
271 });
272 if (!p.ok) throw new Error(JSON.stringify(p.error));
273 const notApp = await previewBuild(app, alice, plain.value, p.value.commitOid);
274 expect(notApp.ok ? undefined : notApp.error).toBe("not_found");
275 const response = await api("POST", "/repos/alice/plain/builds/preview", aliceToken, {
276 sha: p.value.commitOid,
277 });
278 expect(response.status).toBe(404);
279
280 const made = await createRepo(app, alice, { name: "mirrored", visibility: "public" });
281 if (!made.ok) throw new Error(String(made.error));
282 const c = await commitToBranch(app, alice, made.value, {
283 message: "seed",
284 files: [{ path: "README.md", content: "hi\n" }],
285 });
286 if (!c.ok) throw new Error(JSON.stringify(c.error));
287 const origin = createOrigin(app.db, dataDir, {
288 userId: alice.id,
289 kind: "github",
290 apiUrl: "https://api.github.invalid",
291 webUrl: "https://github.invalid",
292 login: "alice",
293 token: "not-a-real-token",
294 });
295 if (!origin.ok) throw new Error(origin.error);
296 linkRepoToOrigin(app.db, made.value.id, {
297 originId: origin.value.id,
298 fullName: "alice/mirrored",
299 remoteId: "1",
300 htmlUrl: "https://github.invalid/alice/mirrored",
301 });
302 const mirrored = await previewBuild(app, alice, made.value, c.value.commitOid);
303 expect(mirrored.ok ? undefined : mirrored.error).toBe("mirrored");
304 const viaApi = await api("POST", "/repos/alice/mirrored/builds/preview", aliceToken, {
305 sha: c.value.commitOid,
306 });
307 expect(viaApi.status).toBe(422);
308 expect(((await viaApi.json()) as { error: string }).error).toBe("mirrored");
309 });
310
311 test("a writer previews a commit: it runs at <slug>--b<sha7>, with production's data copied", async () => {
312 const sha = commits[1]!;
313 const short = sha.slice(0, 7);
314 const response = await api("POST", "/repos/alice/show/builds/preview", aliceToken, { sha });
315 expect(response.status).toBe(201);
316 const body = (await response.json()) as { env: string; url: string };
317 expect(body.env).toBe(`build-${short}`);
318 expect(body.url).toBe(`http://show--b${short}.${DOMAIN}:${servers.port}`);
319
320 const run = await settled((r) => r.env === `build-${short}` && r.commitOid === sha);
321 expect(run.state).toBe("ok");
322 const status = builds()[0];
323 expect(status?.env).toBe(`build-${short}`);
324 expect(status?.state).toBe("running");
325 expect(status?.commitOid).toBe(sha);
326 expect(status?.url).toBe(body.url);
327 expect(runtime.status(record).map((e) => e.env)).toEqual(["production", `build-${short}`]);
328
329 const page = await (await viaHost(`show--b${short}.${DOMAIN}`)).text();
330 expect(page).toContain(sha);
331 expect(page).toContain(`show--b${short}.${DOMAIN}`);
332 expect(page).toContain("written by production");
333 // Production still serves the default branch.
334 expect(await (await viaHost(`show.${DOMAIN}`)).text()).toContain(commits[0]!);
335
336 const events = listEvents(app.db, { kind: "app.build_preview" });
337 expect(events[0]?.payload["oid"]).toBe(sha);
338 }, 90_000);
339
340 test("the same commit twice is one environment, and nothing redeploys", async () => {
341 const sha = commits[1]!;
342 const deploys = () =>
343 listRuns(app.db, repo.id, { kind: "deploy", limit: 50 }).filter(
344 (r) => r.env === `build-${sha.slice(0, 7)}`,
345 ).length;
346 const before = deploys();
347 const again = await previewBuild(app, alice, repo, sha);
348 expect(again.ok).toBe(true);
349 if (again.ok) {
350 expect(again.value.env).toBe(`build-${sha.slice(0, 7)}`);
351 expect(again.value.run).toBeUndefined();
352 }
353 // The API door answers with the environment and where it is served.
354 const door = await fetch(`${servers.url}/api/v1/repos/alice/show/builds/preview`, {
355 method: "POST",
356 headers: { authorization: `Bearer ${aliceToken}`, "content-type": "application/json" },
357 body: JSON.stringify({ sha }),
358 });
359 expect(door.status).toBe(201);
360 const said = (await door.json()) as { env: string; url: string };
361 expect(said.env).toBe(`build-${sha.slice(0, 7)}`);
362 expect(said.url).toContain(`show--b${sha.slice(0, 7)}.${DOMAIN}`);
363 const refused = await fetch(`${servers.url}/api/v1/repos/alice/show/builds/preview`, {
364 method: "POST",
365 headers: { authorization: `Bearer ${aliceToken}`, "content-type": "application/json" },
366 body: JSON.stringify({ sha: "not-a-sha" }),
367 });
368 expect(refused.status).toBe(422);
369 expect(deploys()).toBe(before);
370 expect(builds().length).toBe(1);
371 });
372
373 test(`a build beyond ${BUILD_PREVIEWS_MAX} takes the oldest's place`, async () => {
374 const [, oldest, b, c, d] = commits as [string, string, string, string, string];
375 for (const sha of [b, c]) {
376 const asked = await previewBuild(app, alice, repo, sha);
377 expect(asked.ok).toBe(true);
378 }
379 expect(builds().map((e): string => e.env).sort()).toEqual(
380 [oldest, b, c].map((s) => `build-${s.slice(0, 7)}`).sort(),
381 );
382 const fourth = await previewBuild(app, alice, repo, d);
383 expect(fourth.ok).toBe(true);
384 expect(builds().map((e): string => e.env).sort()).toEqual(
385 [b, c, d].map((s) => `build-${s.slice(0, 7)}`).sort(),
386 );
387 // The oldest is gone, page and all.
388 const gone = await viaHost(`show--b${oldest.slice(0, 7)}.${DOMAIN}`);
389 expect(gone.status).toBe(404);
390 expect(await gone.text()).toContain("the build at");
391 const run = await settled((r) => r.env === `build-${d.slice(0, 7)}` && r.commitOid === d);
392 expect(run.state).toBe("ok");
393 expect(await (await viaHost(`show--b${d.slice(0, 7)}.${DOMAIN}`)).text()).toContain(d);
394 }, 120_000);
395
396 test("the forge coming back does not rebuild a build preview", async () => {
397 await runtime.stop();
398 runtime = new AppsRuntime(app, {
399 domain: DOMAIN,
400 scheme: "http",
401 publicPort: servers.port,
402 forgeUrl: servers.url,
403 });
404 await runtime.start();
405 expect(builds()).toEqual([]);
406 expect(runtime.status(record).map((e) => e.env)).toEqual(["production"]);
407 }, 60_000);
408
409 test(`a person may ask ${BUILD_ASKS_MAX} times per app, then is told to wait`, async () => {
410 const sha = commits[1]!;
411 let last = await previewBuild(app, alice, repo, sha);
412 for (let k = 0; k < BUILD_ASKS_MAX && last.ok; k++) last = await previewBuild(app, alice, repo, sha);
413 expect(last.ok ? undefined : last.error).toBe("rate_limited");
414 const door = await api("POST", "/repos/alice/show/builds/preview", aliceToken, { sha });
415 expect(door.status).toBe(429);
416 });
417
418 });
test/build-core.test.ts added +113
@@ -0,0 +1,113 @@
1 /**
2 * What build previews and Iterations stand on: the build environment's name,
3 * the exact and leased ref moves, and a page's one extra frame origin.
4 * Real git in a temp directory, nothing mocked.
5 */
6
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { buildEnv, buildEnvFromLabel, envBuild, envHostLabel, envPull, envWords, isEnvName } from "../src/apps/data.ts";
12 import { exactRefOid, moveRefLeased } from "../src/git/branches.ts";
13 import { frameSources } from "../src/web/layout.ts";
14 import { gitClientEnv } from "./helpers/git-client.ts";
15
16 const IDENTITY = {
17 GIT_AUTHOR_NAME: "Ada",
18 GIT_AUTHOR_EMAIL: "[email protected]",
19 GIT_COMMITTER_NAME: "Ada",
20 GIT_COMMITTER_EMAIL: "[email protected]",
21 };
22
23 async function sh(args: string[], cwd: string): Promise<string> {
24 const proc = Bun.spawn(["git", ...args], { cwd, env: gitClientEnv(IDENTITY), stdout: "pipe", stderr: "pipe" });
25 const [out, err, code] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]);
26 if (code !== 0) throw new Error(`git ${args.join(" ")}: ${err}`);
27 return out.trim();
28 }
29
30 describe("the build environment's name", () => {
31 test("is the short sha, and reads back from names, labels and hosts", () => {
32 const env = buildEnv("1A2B3C4D5E6F");
33 expect(env).toBe("build-1a2b3c4");
34 expect(isEnvName(env)).toBe(true);
35 expect(envBuild(env)).toBe("1a2b3c4");
36 expect(envHostLabel("show", env)).toBe("show--b1a2b3c4");
37 expect(buildEnvFromLabel("b1a2b3c4")).toBe(env);
38 expect(envWords(env)).toBe("the build at 1a2b3c4");
39 expect(envPull(env)).toBeUndefined();
40 });
41
42 test("production and a pull keep their names, and a short or odd sha is no environment", () => {
43 expect(envHostLabel("show", "production")).toBe("show");
44 expect(envHostLabel("show", "pull-12")).toBe("show--12");
45 expect(envBuild("pull-12")).toBeUndefined();
46 expect(envPull("pull-12")).toBe(12);
47 expect(isEnvName("build-1a2b3c")).toBe(false);
48 expect(isEnvName("build-1a2b3cz")).toBe(false);
49 expect(buildEnvFromLabel("b1a2b3c")).toBeUndefined();
50 });
51 });
52
53 describe("exact and leased refs", () => {
54 let dir: string;
55 let one = "";
56 let two = "";
57
58 beforeAll(async () => {
59 dir = await mkdtemp(join(tmpdir(), "forge-build-core-"));
60 await sh(["init", "-q", "-b", "main"], dir);
61 await sh(["commit", "-q", "--allow-empty", "-m", "one"], dir);
62 one = await sh(["rev-parse", "HEAD"], dir);
63 await sh(["commit", "-q", "--allow-empty", "-m", "two"], dir);
64 two = await sh(["rev-parse", "HEAD"], dir);
65 });
66 afterAll(() => rm(dir, { recursive: true, force: true }));
67
68 test("exactRefOid reads a full ref and never a branch that shares its short name", async () => {
69 expect(await exactRefOid(dir, "refs/heads/main")).toBe(two);
70 expect(await exactRefOid(dir, "refs/pin")).toBeUndefined();
71 await sh(["branch", "pin", one], dir);
72 expect(await exactRefOid(dir, "refs/pin")).toBeUndefined();
73 expect(await exactRefOid(dir, "pin")).toBeUndefined();
74 expect(await exactRefOid(dir, "refs/../x")).toBeUndefined();
75 });
76
77 test("moveRefLeased creates, moves and deletes only from the value the caller saw", async () => {
78 const ref = "refs/pins/a";
79 expect((await moveRefLeased(dir, ref, one, undefined)).ok).toBe(true);
80 expect(await exactRefOid(dir, ref)).toBe(one);
81 // Creating over an existing ref, and moving from a stale value, are refused.
82 expect(await moveRefLeased(dir, ref, two, undefined)).toEqual({ ok: false, error: "ref_moved" });
83 expect(await moveRefLeased(dir, ref, two, two)).toEqual({ ok: false, error: "ref_moved" });
84 expect(await exactRefOid(dir, ref)).toBe(one);
85 expect((await moveRefLeased(dir, ref, two, one)).ok).toBe(true);
86 expect(await exactRefOid(dir, ref)).toBe(two);
87 expect(await moveRefLeased(dir, ref, undefined, one)).toEqual({ ok: false, error: "ref_moved" });
88 expect((await moveRefLeased(dir, ref, undefined, two)).ok).toBe(true);
89 expect(await exactRefOid(dir, ref)).toBeUndefined();
90 });
91
92 test("moveRefLeased refuses a ref outside refs/ and an oid that is not one", async () => {
93 expect(await moveRefLeased(dir, "main", one, undefined)).toEqual({ ok: false, error: "invalid_ref" });
94 expect(await moveRefLeased(dir, "refs/x y", one, undefined)).toEqual({ ok: false, error: "invalid_ref" });
95 expect(await moveRefLeased(dir, "refs/pins/b", "--delete", undefined)).toEqual({ ok: false, error: "invalid_oid" });
96 });
97 });
98
99 describe("a page's frame origin", () => {
100 test("is added to frame-src for the page that asks, beside Turnstile's, and nowhere else", () => {
101 expect(frameSources({})).toBe("");
102 expect(frameSources({ turnstile: true })).toBe("frame-src https://challenges.cloudflare.com; ");
103 expect(frameSources({ frameSrc: "https://d.example.test:8443" })).toBe("frame-src https://d.example.test:8443; ");
104 expect(frameSources({ turnstile: true, frameSrc: "http://localhost:3000" })).toBe(
105 "frame-src https://challenges.cloudflare.com http://localhost:3000; ",
106 );
107 });
108
109 test("ignores anything that is not a bare origin", () => {
110 for (const bad of ["*", "https://a.test/x", "https://a.test; script-src *", "'self'", "data:", "https://a.test *"])
111 expect(frameSources({ frameSrc: bad })).toBe("");
112 });
113 });
test/tell-body.test.ts added +54
@@ -0,0 +1,54 @@
1 /**
2 * An issue opened by a tell keeps its title out of its body: the body is
3 * what follows the first line, as Open issue would file it. A title cut
4 * short keeps the whole text in the body.
5 */
6 import { afterAll, beforeAll, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { createRepo } from "../src/actions/repos.ts";
12 import { tell } from "../src/actions/tell.ts";
13 import type { Repo } from "../src/core/repos.ts";
14 import type { User } from "../src/core/users.ts";
15 import { seedAdmin } from "./helpers/admin.ts";
16 import { quietLogs } from "./helpers/globals.ts";
17
18 quietLogs();
19
20 let dataDir: string;
21 let app: App;
22 let alice: User;
23 let repo: Repo;
24
25 beforeAll(async () => {
26 dataDir = await mkdtemp(join(tmpdir(), "forge-tell-body-"));
27 app = createApp({ dataDir, port: 0 });
28 const a = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-long-tell-passphrase" });
29 if (!a.ok) throw new Error("signup failed");
30 alice = a.value;
31 const made = await createRepo(app, alice, { name: "notes", visibility: "public" });
32 if (!made.ok) throw new Error("repo failed");
33 repo = made.value;
34 });
35
36 afterAll(async () => {
37 app.close();
38 await rm(dataDir, { recursive: true, force: true });
39 });
40
41 test("the body does not repeat the title", async () => {
42 const told = await tell(app, alice, repo, "Make it purple\n\nThe header only.");
43 if (!told.ok) throw new Error(String(told.error));
44 expect(told.value.issue.title).toBe("Make it purple");
45 expect(told.value.issue.body).toBe("The header only.");
46 });
47
48 test("a title cut short keeps the whole text in the body", async () => {
49 const long = `${"word ".repeat(30).trim()}\n\nMore.`;
50 const told = await tell(app, alice, repo, long);
51 if (!told.ok) throw new Error(String(told.error));
52 expect(told.value.issue.title.endsWith("…")).toBe(true);
53 expect(told.value.issue.body).toBe(long);
54 });
test/tui-deploys-build.test.ts added +166
@@ -0,0 +1,166 @@
1 /**
2 * The deploys screen's `b` serves a commit as a build preview (`previewBuild`):
3 * offered to a writer who is a person, it takes a full commit id, says the
4 * address on success and the refusal in words otherwise, and calls nothing
5 * for a malformed id. The runtime's `previewBuild` is faked.
6 */
7
8 import { afterAll, beforeAll, beforeEach, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
13 import { signUp } from "../src/actions/users.ts";
14 import { createApp, type App as Forge } from "../src/app.ts";
15 import { AppsRuntime, type EnvStatus } from "../src/apps/runtime.ts";
16 import { createApp as createAppRecord } from "../src/core/apps.ts";
17 import type { Repo } from "../src/core/repos.ts";
18 import type { Run } from "../src/core/runs.ts";
19 import type { User } from "../src/core/users.ts";
20 import { repoDir } from "../src/git/repos.ts";
21 import { gitClientEnv } from "./helpers/git-client.ts";
22 import { markVerified } from "../src/core/verify.ts";
23 import { createForgeApp } from "../src/tui/forge.ts";
24 import { deploysScreen } from "../src/tui/screens/deploys.ts";
25 import { headlessTerminal } from "../src/tui/term/terminal.ts";
26 import { seedAdmin } from "./helpers/admin.ts";
27 import { quietLogs } from "./helpers/globals.ts";
28 import { fixtureSlot } from "./helpers/slot.ts";
29 import { settleApp } from "./helpers/tui.ts";
30 import { proveAll } from "./helpers/verified.ts";
31
32 quietLogs();
33
34 let dir: string;
35 let forge: Forge;
36 let runtime: AppsRuntime;
37 let alice: User;
38 let bob: User;
39 let repo: Repo;
40 let state: EnvStatus["state"];
41 let deploys: string[];
42 let stops: string[];
43 let builds: string[];
44 let sha = "";
45
46 beforeAll(async () => {
47 dir = await mkdtemp(join(tmpdir(), "forge-tui-deploy-act-"));
48 forge = createApp({ dataDir: dir, port: 0 });
49 const a = await seedAdmin(forge, { username: "alice", email: "[email protected]", password: "a-long-passphrase" });
50 if (!a.ok) throw new Error("signup failed");
51 alice = a.value;
52 const b = await signUp(
53 forge,
54 { username: "bob", email: "[email protected]", password: "a-long-passphrase" },
55 { slot: fixtureSlot(forge) },
56 );
57 if (!b.ok) throw new Error("signup failed");
58 bob = b.value;
59 proveAll(forge);
60 const r = await createRepo(forge, alice, { name: "lamp", visibility: "public" });
61 if (!r.ok) throw new Error("repo failed");
62 repo = r.value;
63 const committed = await commitToBranch(forge, alice, repo, {
64 message: "Light the lamp",
65 files: [{ path: "README.md", content: "lamp\n" }],
66 });
67 if (!committed.ok) throw new Error(JSON.stringify(committed.error));
68 sha = Bun.spawnSync(["git", "rev-parse", "HEAD"], { cwd: repoDir(forge.reposRoot, "alice", "lamp"), env: gitClientEnv() }).stdout.toString().trim();
69 const made = createAppRecord(forge.db, { repoId: repo.id, slug: "lamp", access: "public" });
70 if (!made.ok) throw new Error(made.error);
71 runtime = new AppsRuntime(forge, { domain: "apps.test", scheme: "https", forgeUrl: "https://forge.test", concurrency: 1 });
72 runtime.status = (() => [{ env: "production", state } as EnvStatus]) as typeof runtime.status;
73 runtime.requestDeploy = ((_record, _repo, env) => {
74 deploys.push(env);
75 return { id: "run-1" } as Run;
76 }) as typeof runtime.requestDeploy;
77 runtime.previewBuild = (async (_record, _repo, oid) => {
78 builds.push(oid);
79 return { env: `build-${oid.slice(0, 7)}`, run: undefined };
80 }) as typeof runtime.previewBuild;
81 runtime.stopEnv = (async (_record, env) => {
82 stops.push(env);
83 state = "stopped";
84 }) as typeof runtime.stopEnv;
85 });
86 afterAll(async () => {
87 await runtime.stop();
88 forge.close?.();
89 await rm(dir, { recursive: true, force: true });
90 });
91 beforeEach(() => {
92 state = "running";
93 deploys = [];
94 stops = [];
95 builds = [];
96 });
97
98 const open = async (who: User, of: Repo = repo) => {
99 const { app, session } = createForgeApp(
100 forge,
101 { sshUsername: who.username, remoteAddress: "127.0.0.1", user: who },
102 { terminal: headlessTerminal(200, 30), exitHooks: false },
103 );
104 app.start();
105 await settleApp(app);
106 const toasts: { text: string; tone: string | undefined }[] = [];
107 const toast = app.toast.bind(app);
108 app.toast = (text, tone) => {
109 toasts.push({ text, tone });
110 toast(text, tone);
111 };
112 const screen = deploysScreen(session, app.ctx, of);
113 app.push(screen);
114 await settleApp(app);
115 const press = async (...keys: string[]): Promise<void> => {
116 for (const key of keys) app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
117 await settleApp(app);
118 };
119 const keys = () => screen.keys.map((b) => b.help);
120 return { app, press, keys, toasts, text: () => app.screen.toText() };
121 };
122
123 test("b asks for a commit id and serves it, saying where", async () => {
124 const h = await open(alice);
125 expect(h.keys()).toContain("preview a commit");
126 await h.press("b");
127 expect(h.text()).toContain("Commit id");
128 for (const ch of sha) await h.press(ch);
129 await h.press("enter");
130 expect(builds).toEqual([sha]);
131 expect(h.toasts.at(-1)?.text).toContain(`Previewing ${sha.slice(0, 7)} at `);
132 expect(h.toasts.at(-1)?.text).toContain(`lamp--b${sha.slice(0, 7)}.apps.test`);
133 h.app.stop();
134 });
135
136 test("a malformed id keeps the prompt open and asks for nothing", async () => {
137 const h = await open(alice);
138 await h.press("b", "x", "enter");
139 expect(h.text()).toContain("Give the full 40-character commit id.");
140 expect(builds).toEqual([]);
141 h.app.stop();
142 });
143
144 test("a reader is offered no key and pressing it does nothing", async () => {
145 const h = await open(bob);
146 expect(h.keys()).not.toContain("preview a commit");
147 await h.press("b");
148 expect(h.text()).not.toContain("Commit id");
149 expect(builds).toEqual([]);
150 h.app.stop();
151 });
152
153 test("a writer who is unverified is told to verify, and nothing is served", async () => {
154 forge.db.query("UPDATE email_check SET verified_at = NULL WHERE user_id = ?1").run(alice.id);
155 try {
156 const h = await open(alice);
157 await h.press("b");
158 for (const ch of sha) await h.press(ch);
159 await h.press("enter");
160 expect(builds).toEqual([]);
161 expect(h.toasts.at(-1)).toEqual({ text: expect.stringContaining("Verify your email first"), tone: "danger" });
162 h.app.stop();
163 } finally {
164 markVerified(forge.db, alice.id, new Date().toISOString());
165 }
166 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs