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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9041-bring-in-jay-moran-s-build-pins-and-iter → main 4 commits fork 1 thread · 1 unresolved
23 files
changed +1259
−49
docs/APPS.md +5 −1
| @@ -1016,7 +1016,11 @@ someone tries. | ||
| 1016 | 1016 | Apps are served from the forge's port by `Host`. With `FORGE_APP_DOMAIN` |
| 1017 | 1017 | (default: the forge's own host), production is `<slug>.<domain>` and the |
| 1018 | 1018 | 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 | |
| 1020 | 1024 | wildcard certificate covers one label), give the attachment itself: |
| 1021 | 1025 | `FORGE_APP_DOMAIN=--forge.example` serves `notes--forge.example` and |
| 1022 | 1026 | `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 | ||
| 24 | 24 | import { plainLine } from "../apps/side.ts"; |
| 25 | 25 | import { scaffoldFiles } from "../apps/scaffold.ts"; |
| 26 | 26 | import { |
| 27 | envBuild, | |
| 27 | 28 | isEnvName, |
| 28 | 29 | previewEnv, |
| 29 | 30 | type EnvName, |
| @@ -720,7 +721,7 @@ export async function redeployEnv( | ||
| 720 | 721 | if (!isEnvName(env)) return err("no_such_env"); |
| 721 | 722 | const runtime = appsRuntime(app); |
| 722 | 723 | if (runtime === undefined) return err("no_runtime"); |
| 723 | const commit = await commitFor(app, repo, env); | |
| 724 | const commit = await commitFor(app, repo, record, env); | |
| 724 | 725 | if (commit === undefined) return err("nothing_to_deploy"); |
| 725 | 726 | return ok( |
| 726 | 727 | runtime.requestDeploy( |
| @@ -761,11 +762,16 @@ export async function stopEnv( | ||
| 761 | 762 | async function commitFor( |
| 762 | 763 | app: App, |
| 763 | 764 | repo: Repo, |
| 765 | record: AppRecord, | |
| 764 | 766 | env: EnvName, |
| 765 | 767 | ): Promise<string | undefined> { |
| 766 | 768 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); |
| 767 | 769 | if (env === "production") |
| 768 | 770 | 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; | |
| 769 | 775 | const pull = getPull(app.db, repo.id, Number(env.slice(5))); |
| 770 | 776 | if (!pull || pull.state !== "open") return undefined; |
| 771 | 777 | const at = await pullDir(app.db, app.reposRoot, repo, pull); |
src/actions/build_preview.ts added +129
| @@ -0,0 +1,129 @@ | ||
| 1 | /** | |
| 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 @@ | ||
| 4 | 4 | * terminal's deploys screen, from here. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { envPull, type EnvName } from "../apps/data.ts"; | |
| 7 | import { envBuild, envPull, type EnvName } from "../apps/data.ts"; | |
| 8 | 8 | import type { Run } from "../core/runs.ts"; |
| 9 | 9 | import { VERIFY_FIRST } from "./verify_words.ts"; |
| 10 | 10 | |
| 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. */ | |
| 12 | 12 | export function envLabel(env: EnvName): string { |
| 13 | const build = envBuild(env); | |
| 14 | if (build !== undefined) return `build ${build}`; | |
| 13 | 15 | const number = envPull(env); |
| 14 | 16 | return number === undefined ? "production" : `preview of #${number}`; |
| 15 | 17 | } |
src/actions/tell.ts +4 −1
| @@ -117,7 +117,10 @@ export async function tell( | ||
| 117 | 117 | for (let k = 1; k <= variants; k++) { |
| 118 | 118 | const note = many ? `Variant ${k} of ${variants}.` : ""; |
| 119 | 119 | 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; | |
| 121 | 124 | const opened = openIssue(app, actor, repo, { |
| 122 | 125 | title, |
| 123 | 126 | body: [rest, note, origin].filter((s) => s !== "").join("\n\n"), |
src/apps/data.ts +51 −6
| @@ -34,18 +34,63 @@ import { | ||
| 34 | 34 | import { join } from "node:path"; |
| 35 | 35 | import { defect } from "../kernel/error.ts"; |
| 36 | 36 | |
| 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}`; | |
| 39 | 42 | |
| 40 | 43 | export const previewEnv = (pullNumber: number): EnvName => `pull-${pullNumber}`; |
| 41 | 44 | |
| 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; | |
| 44 | 51 | } |
| 45 | 52 | |
| 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. */ | |
| 47 | 62 | 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)}`; | |
| 49 | 94 | } |
| 50 | 95 | |
| 51 | 96 | export const appsRoot = (dataDir: string): string => join(dataDir, "apps"); |
src/apps/proxy.ts +28 −10
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * 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 | |
| 4 | 5 | * from the deployment's loopback port and streamed back. A websocket |
| 5 | 6 | * handshake is the same request until the app accepts it — then the forge |
| 6 | 7 | * holds one socket to the browser and one to the app and carries frames |
| @@ -38,7 +39,13 @@ import { verifyToken } from "../core/tokens.ts"; | ||
| 38 | 39 | import { getUserById, isActive, type User } from "../core/users.ts"; |
| 39 | 40 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 40 | 41 | 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"; | |
| 42 | 49 | import type { AppsRuntime, EnvStatus } from "./runtime.ts"; |
| 43 | 50 | import { crewNow, tellLink } from "./crew.ts"; |
| 44 | 51 | import { sideOf, type Side } from "./side.ts"; |
| @@ -97,11 +104,17 @@ export function parseAppHost( | ||
| 97 | 104 | if (label === "" || label.includes(".")) return undefined; |
| 98 | 105 | const parts = label.split("--"); |
| 99 | 106 | 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. | |
| 104 | 113 | 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 | } | |
| 105 | 118 | if (parts.length >= 2 && /^\d+$/.test(last)) { |
| 106 | 119 | if (!/^[1-9]\d*$/.test(last)) return undefined; |
| 107 | 120 | return { |
| @@ -1628,9 +1641,9 @@ export function unavailable( | ||
| 1628 | 1641 | ): Response { |
| 1629 | 1642 | const name = target.app.slug; |
| 1630 | 1643 | 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); | |
| 1634 | 1647 | const state = status?.state ?? "stopped"; |
| 1635 | 1648 | const deploying = state === "deploying"; |
| 1636 | 1649 | // A preview is gone when its pull is closed, merged or no longer exists: |
| @@ -1649,7 +1662,12 @@ export function unavailable( | ||
| 1649 | 1662 | // environment keeps its 502. |
| 1650 | 1663 | const absent = preview && !gone && status === undefined; |
| 1651 | 1664 | 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); | |
| 1653 | 1671 | const links = discoveryLinks(runtime, target); |
| 1654 | 1672 | const report = links.length > 0; |
| 1655 | 1673 | const runLink = |
src/apps/runtime.ts +65 −18
| @@ -81,6 +81,9 @@ import { DRAIN_BUDGET_MS, drainPort } from "./handoff.ts"; | ||
| 81 | 81 | import type { DomainResolver } from "./domains.ts"; |
| 82 | 82 | import { |
| 83 | 83 | appsRoot, |
| 84 | buildEnv, | |
| 85 | envBuild, | |
| 86 | envHostLabel, | |
| 84 | 87 | ensureEnvData, |
| 85 | 88 | envDataDir, |
| 86 | 89 | envDir, |
| @@ -353,8 +356,13 @@ interface Environment { | ||
| 353 | 356 | lastErrorCommitOid?: string | undefined; |
| 354 | 357 | /** Crash restarts in the current window, for backing off. */ |
| 355 | 358 | restarts: number[]; |
| 359 | /** A build preview's place in the order build previews were started, oldest lowest. */ | |
| 360 | buildOrder?: number | undefined; | |
| 356 | 361 | } |
| 357 | 362 | |
| 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 | ||
| 358 | 366 | /** The deploy reasons that mean a person touched the change. */ |
| 359 | 367 | const TOUCH_REASONS: readonly string[] = [ |
| 360 | 368 | "push", |
| @@ -396,13 +404,13 @@ export function envsFor( | ||
| 396 | 404 | return runtime.status(record); |
| 397 | 405 | } |
| 398 | 406 | |
| 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); | |
| 406 | 414 | |
| 407 | 415 | export class AppsRuntime { |
| 408 | 416 | private readonly envs = new Map<string, Environment>(); |
| @@ -415,6 +423,8 @@ export class AppsRuntime { | ||
| 415 | 423 | private unsubscribe: (() => void) | undefined; |
| 416 | 424 | private sweeper: ReturnType<typeof setInterval> | undefined; |
| 417 | 425 | private stopped = false; |
| 426 | /** Counts build previews as they start, so the oldest is the lowest. */ | |
| 427 | private buildCount = 0; | |
| 418 | 428 | |
| 419 | 429 | constructor( |
| 420 | 430 | readonly app: App, |
| @@ -437,8 +447,7 @@ export class AppsRuntime { | ||
| 437 | 447 | // ---- addressing ---- |
| 438 | 448 | |
| 439 | 449 | 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)}`; | |
| 442 | 451 | } |
| 443 | 452 | |
| 444 | 453 | /** |
| @@ -446,9 +455,7 @@ export class AppsRuntime { | ||
| 446 | 455 | * login, the forge is the host — or undefined when SSH is off. |
| 447 | 456 | */ |
| 448 | 457 | 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)); | |
| 452 | 459 | } |
| 453 | 460 | |
| 454 | 461 | /** |
| @@ -509,8 +516,8 @@ export class AppsRuntime { | ||
| 509 | 516 | for (const state of this.envs.values()) { |
| 510 | 517 | if (state.app.id === record.id) out.push(this.describe(state)); |
| 511 | 518 | } |
| 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`). | |
| 514 | 521 | return out.sort(byEnv); |
| 515 | 522 | } |
| 516 | 523 | |
| @@ -743,8 +750,8 @@ export class AppsRuntime { | ||
| 743 | 750 | |
| 744 | 751 | /** |
| 745 | 752 | * 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. | |
| 748 | 755 | */ |
| 749 | 756 | async reconcileApp( |
| 750 | 757 | record: AppRecord, |
| @@ -757,6 +764,11 @@ export class AppsRuntime { | ||
| 757 | 764 | if (tip !== undefined && only !== "preview") |
| 758 | 765 | this.requestDeploy(record, repo, "production", tip, reason); |
| 759 | 766 | 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); | |
| 760 | 772 | for (const pull of listPulls(this.app.db, repo.id, { sealed: true, |
| 761 | 773 | state: "open", |
| 762 | 774 | limit: 200, |
| @@ -1025,7 +1037,7 @@ export class AppsRuntime { | ||
| 1025 | 1037 | return state.deploying.run; |
| 1026 | 1038 | } |
| 1027 | 1039 | const issue = |
| 1028 | env === "production" ? undefined : this.pullIssueId(repo, env); | |
| 1040 | envPull(env) === undefined ? undefined : this.pullIssueId(repo, env); | |
| 1029 | 1041 | const run = createRun(this.app.db, { |
| 1030 | 1042 | repoId: repo.id, |
| 1031 | 1043 | kind: "deploy", |
| @@ -1053,6 +1065,38 @@ export class AppsRuntime { | ||
| 1053 | 1065 | return run; |
| 1054 | 1066 | } |
| 1055 | 1067 | |
| 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 | ||
| 1056 | 1100 | private pullIssueId(repo: Repo, env: EnvName): string | undefined { |
| 1057 | 1101 | const pull = getPull(this.app.db, repo.id, Number(env.slice(5))); |
| 1058 | 1102 | return pull?.id; |
| @@ -1669,7 +1713,10 @@ export class AppsRuntime { | ||
| 1669 | 1713 | env: EnvName, |
| 1670 | 1714 | commitOid: string, |
| 1671 | 1715 | ): 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; | |
| 1673 | 1720 | const number = envPull(env); |
| 1674 | 1721 | const pull = |
| 1675 | 1722 | 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"; | ||
| 27 | 27 | import type { User } from "../core/users.ts"; |
| 28 | 28 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 29 | 29 | 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"; | |
| 31 | 36 | import { appEnv, signalGroup } from "./process.ts"; |
| 32 | 37 | import { appsRuntime } from "./runtime.ts"; |
| 33 | 38 | |
| 34 | 39 | const CRLF = "\r\n"; |
| 35 | 40 | |
| 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. */ | |
| 37 | 42 | export function parseAppLogin( |
| 38 | 43 | login: string, |
| 39 | 44 | ): { slug: string; env: EnvName } | undefined { |
| @@ -43,6 +48,8 @@ export function parseAppLogin( | ||
| 43 | 48 | return isSlugLike(name) ? { slug: name, env: "production" } : undefined; |
| 44 | 49 | const slug = name.slice(0, at); |
| 45 | 50 | const number = name.slice(at + 2); |
| 51 | const build = buildEnvFromLabel(number); | |
| 52 | if (isSlugLike(slug) && build !== undefined) return { slug, env: build }; | |
| 46 | 53 | if (!isSlugLike(slug) || !/^[1-9]\d*$/.test(number)) return undefined; |
| 47 | 54 | const env = previewEnv(Number(number)); |
| 48 | 55 | return isEnvName(env) ? { slug, env } : undefined; |
src/contract/openapi/routes/index.ts +1
| @@ -248,6 +248,7 @@ export { route as postApiV1ReposOwnerNameAppNotesIdBan } from "./postApiV1ReposO | ||
| 248 | 248 | export { route as postApiV1ReposOwnerNameAppNotesIdHide } from "./postApiV1ReposOwnerNameAppNotesIdHide.ts"; |
| 249 | 249 | export { route as postApiV1ReposOwnerNameAppNotesIdUnban } from "./postApiV1ReposOwnerNameAppNotesIdUnban.ts"; |
| 250 | 250 | export { route as postApiV1ReposOwnerNameBranches } from "./postApiV1ReposOwnerNameBranches.ts"; |
| 251 | export { route as postApiV1ReposOwnerNameBuildsPreview } from "./postApiV1ReposOwnerNameBuildsPreview.ts"; | |
| 251 | 252 | export { route as postApiV1ReposOwnerNameChecksRecheck } from "./postApiV1ReposOwnerNameChecksRecheck.ts"; |
| 252 | 253 | export { route as postApiV1ReposOwnerNameContents } from "./postApiV1ReposOwnerNameContents.ts"; |
| 253 | 254 | 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 => ({ | ||
| 45 | 45 | createdAt: r.created_at, |
| 46 | 46 | }); |
| 47 | 47 | |
| 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 | ||
| 48 | 55 | /** |
| 49 | 56 | * A slug is a DNS label: lowercase letters, digits, single hyphens inside. |
| 50 | 57 | * `--` is reserved as the separator before a preview number, so a slug can |
| @@ -54,7 +61,12 @@ export const SLUG_MAX = 40; | ||
| 54 | 61 | const SLUG = /^[a-z0-9](?:-?[a-z0-9])*$/; |
| 55 | 62 | |
| 56 | 63 | 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 | ); | |
| 58 | 70 | } |
| 59 | 71 | |
| 60 | 72 | /** The slug a repository name suggests: lowercased, runs of junk to one `-`. */ |
| @@ -65,7 +77,7 @@ export function slugFor(name: string): string { | ||
| 65 | 77 | .replace(/^-+|-+$/g, "") |
| 66 | 78 | .slice(0, SLUG_MAX) |
| 67 | 79 | .replace(/-+$/g, ""); |
| 68 | return slug === "" ? "app" : slug; | |
| 80 | return slug === "" ? "app" : reserved(slug) ? `${slug}-app` : slug; | |
| 69 | 81 | } |
| 70 | 82 | |
| 71 | 83 | export type CreateAppError = "invalid_slug" | "slug_taken" | "already_app"; |
src/git/branches.ts +58
| @@ -357,3 +357,61 @@ export async function startOid( | ||
| 357 | 357 | } |
| 358 | 358 | return await resolveOid(dir, from); |
| 359 | 359 | } |
| 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 @@ | ||
| 9 | 9 | import { readAppMembers, setAppMembersThrough, type AppMembersError } from "../../actions/app_members.ts"; |
| 10 | 10 | import { appView, appVisitors, deleteAppEnvVar, disableApp, enableApp, listAppEnv, openEnvUrl, redeployEnv, setAppEnvVar, stopEnv, updateAppSettings, type AppSettingsInput } from "../../actions/apps.ts"; |
| 11 | 11 | import { addAppDomain, checkAppDomain, listAppDomains, removeAppDomain } from "../../actions/domains.ts"; |
| 12 | import { previewBuild, previewBuildRefusalText } from "../../actions/build_preview.ts"; | |
| 12 | 13 | import { SETTING_WORDS } from "../../actions/setting_words.ts"; |
| 13 | 14 | import { appsRuntime } from "../../apps/runtime.ts"; |
| 14 | 15 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| @@ -292,6 +293,24 @@ export function appRoutes(router: Router<Handler>): void { | ||
| 292 | 293 | }, |
| 293 | 294 | ); |
| 294 | 295 | |
| 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 | ||
| 295 | 314 | // The URL to open an environment at. For a private app it carries a |
| 296 | 315 | // grant, so the answer is for the caller alone and is not cached. |
| 297 | 316 | router.add("GET", "/api/v1/repos/:owner/:name/app/:env/open", (ctx) => { |
src/looks/service.ts +9 −2
| @@ -30,7 +30,7 @@ import { | ||
| 30 | 30 | } from "node:fs"; |
| 31 | 31 | import { dirname, join } from "node:path"; |
| 32 | 32 | 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"; | |
| 34 | 34 | import { signGrant, type GrantWho } from "../apps/proxy.ts"; |
| 35 | 35 | import { appsRuntime } from "../apps/runtime.ts"; |
| 36 | 36 | import { |
| @@ -654,7 +654,14 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle { | ||
| 654 | 654 | } |
| 655 | 655 | retakeStrangerThumbs(record); |
| 656 | 656 | 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; | |
| 658 | 665 | const pull = getPull(app.db, record.repoId, Number(env.slice(5))); |
| 659 | 666 | if ( |
| 660 | 667 | pull === undefined || |
src/tui/screens/apptty.ts +2 −3
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import { runAppTty, ttyRefusalText } from "../../apps/tty.ts"; |
| 9 | import type { EnvName } from "../../apps/data.ts"; | |
| 9 | import { envHostLabel, type EnvName } from "../../apps/data.ts"; | |
| 10 | 10 | import { getAppByRepo } from "../../core/apps.ts"; |
| 11 | 11 | import type { Repo } from "../../core/repos.ts"; |
| 12 | 12 | import type { Ctx } from "../app/app.ts"; |
| @@ -23,8 +23,7 @@ export async function openAppTerminal( | ||
| 23 | 23 | ctx.toast("this repository is not an app", "attn"); |
| 24 | 24 | return; |
| 25 | 25 | } |
| 26 | const name = | |
| 27 | env === "production" ? record.slug : `${record.slug}--${env.slice(5)}`; | |
| 26 | const name = envHostLabel(record.slug, env); | |
| 28 | 27 | const ran = await ctx.suspend((host) => |
| 29 | 28 | runAppTty(f.app, host, f.user, { app: record, env }), |
| 30 | 29 | ); |
src/tui/screens/deploys.ts +35
| @@ -17,6 +17,10 @@ | ||
| 17 | 17 | * each: `#N title · check <word> · <preview address or none>`. A guest sees |
| 18 | 18 | * none. |
| 19 | 19 | * |
| 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 | * | |
| 20 | 24 | * A writer redeploys production with `D` and stops it with `S`, each after a |
| 21 | 25 | * one-line confirm, through `redeployEnv` and `stopEnv` — the page's and MCP |
| 22 | 26 | * `app_deploy`'s actions — and is told the answer in the page's words |
| @@ -64,6 +68,7 @@ import { | ||
| 64 | 68 | type AppPreview, |
| 65 | 69 | type AppPullLine, |
| 66 | 70 | } from "../../actions/apps.ts"; |
| 71 | import { previewBuild, previewBuildRefusalText } from "../../actions/build_preview.ts"; | |
| 67 | 72 | import { STOP_APP } from "../../web/danger_words.ts"; |
| 68 | 73 | import { envPhrase } from "../../apps/env_phrase.ts"; |
| 69 | 74 | import { |
| @@ -250,6 +255,31 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 250 | 255 | const openPreview = (p: AppPreview): void => ctx.push(pullScreen(f, ctx, local(f, repo), p.number)); |
| 251 | 256 | // The MCP tool's gate, read once: only a writer is offered the knock. |
| 252 | 257 | 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 | }; | |
| 253 | 283 | |
| 254 | 284 | /** Redeploy or stop `env` and say the answer in the page's words; one path for production and previews. */ |
| 255 | 285 | 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 { | ||
| 347 | 377 | ...(domains !== undefined ? [binding(["n"], "the app's own domains")] : []), |
| 348 | 378 | ...(prompts ? [binding(["i"], "prompt source (Instagram)")] : []), |
| 349 | 379 | ...(stoppable ? [binding(["X"], STOP_APP.toLowerCase())] : []), |
| 380 | ...(mayBuild ? [binding(["b"], "preview a commit")] : []), | |
| 350 | 381 | ]; |
| 351 | 382 | }, |
| 352 | 383 | load: async () => read(), |
| @@ -494,6 +525,10 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 494 | 525 | ctx.push(probeScreen(f, ctx, repo)); |
| 495 | 526 | return true; |
| 496 | 527 | } |
| 528 | if (e.key === "b" && mayBuild) { | |
| 529 | previewACommit(); | |
| 530 | return true; | |
| 531 | } | |
| 497 | 532 | if (e.key === "m" && members) { |
| 498 | 533 | ctx.push(appMembersScreen(f, ctx, repo)); |
| 499 | 534 | return true; |
src/web/layout.ts +19 −1
| @@ -144,6 +144,12 @@ export interface PageOptions { | ||
| 144 | 144 | * nothing else on this page is relaxed. |
| 145 | 145 | */ |
| 146 | 146 | 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; | |
| 147 | 153 | /** Extra palette rows for this page's context (a repo's own tabs). */ |
| 148 | 154 | palette?: PaletteItem[]; |
| 149 | 155 | /** |
| @@ -821,7 +827,7 @@ export function page(options: PageOptions, status = 200): Response { | ||
| 821 | 827 | // The hashed stylesheet and scripts are 'self'; the two inline <style>s |
| 822 | 828 | // are named by hash. connect-src 'self' lets the live event stream |
| 823 | 829 | // (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'`, | |
| 825 | 831 | "referrer-policy": "same-origin", |
| 826 | 832 | "x-content-type-options": "nosniff", |
| 827 | 833 | // Markup is revalidated every time; only the hashed assets it names are |
| @@ -857,6 +863,18 @@ export function page(options: PageOptions, status = 200): Response { | ||
| 857 | 863 | |
| 858 | 864 | const TURNSTILE_ORIGIN = "https://challenges.cloudflare.com"; |
| 859 | 865 | |
| 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 | ||
| 860 | 878 | export interface RedirectOptions { |
| 861 | 879 | /** A Set-Cookie value to send alongside (session, theme). */ |
| 862 | 880 | 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 | }); |
new above
offline
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).